Coverage for qml_essentials / ansaetze.py: 95%

371 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-09-03 21:15 +0000

1from abc import ABC, abstractmethod 

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

3import jax.numpy as np 

4import logging 

5import warnings 

6 

7from jaqsi.gates import Gates, PulseInformation 

8from jaqsi.gateset import DiagonalQubitUnitary 

9from jaqsi.unitary import UnitaryGates 

10 

11from qml_essentials.topologies import Topology 

12 

13log = logging.getLogger(__name__) 

14 

15 

16class Circuit(ABC): 

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

18 

19 def __init__(self) -> None: 

20 """Initialize the circuit.""" 

21 pass 

22 

23 @abstractmethod 

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

25 """ 

26 Get the number of parameters per circuit layer. 

27 

28 Args: 

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

30 

31 Returns: 

32 int: Number of parameters required per layer. 

33 

34 Raises: 

35 NotImplementedError: Must be implemented by subclasses. 

36 """ 

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

38 

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

40 """ 

41 Get the number of pulse parameters per circuit layer. 

42 

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

44 override this method. 

45 

46 Args: 

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

48 

49 Returns: 

50 int: Number of pulse parameters required per layer. 

51 

52 Raises: 

53 NotImplementedError: If called but not overridden by subclass. 

54 """ 

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

56 

57 @abstractmethod 

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

59 """ 

60 Get indices for controlled rotation gates in one layer. 

61 

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

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

64 

65 Args: 

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

67 

68 Returns: 

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

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

71 rotation gates. 

72 

73 Raises: 

74 NotImplementedError: Must be implemented by subclasses. 

75 """ 

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

77 

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

79 """ 

80 Extract angles for controlled rotation gates from parameter array. 

81 

82 Args: 

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

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

85 

86 Returns: 

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

88 or empty array if circuit contains no controlled gates. 

89 """ 

90 indices = self.get_control_indices(n_qubits) 

91 if indices is None: 

92 return np.array([]) 

93 

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

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

96 else: 

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

98 

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

100 """ 

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

102 

103 Internal method that handles pulse parameter validation and context 

104 management before delegating to the build() method. 

105 

106 Args: 

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

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

109 **kwargs: Additional keyword arguments: 

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

111 Defaults to False. 

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

113 - noise_params (Dict): Noise parameters dictionary. 

114 

115 Returns: 

116 Any: Result from the build() method. 

117 

118 Raises: 

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

120 """ 

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

122 

123 if pulse and "pulse_params" in kwargs: 

124 pulse_params_per_layer = self.n_pulse_params_per_layer(n_qubits) 

125 

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

127 raise ValueError( 

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

129 f"does not match expected {pulse_params_per_layer} " 

130 f"for {n_qubits} qubits" 

131 ) 

132 

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

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

135 else: 

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

137 

138 @abstractmethod 

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

140 """ 

141 Build one layer of the quantum circuit. 

142 

143 Args: 

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

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

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

147 

148 Returns: 

149 Any: Circuit construction result. 

150 

151 Raises: 

152 NotImplementedError: Must be implemented by subclasses. 

153 """ 

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

155 

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

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

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

159 

160 

161class DeclarativeCircuit(Circuit): 

162 """ 

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

164 

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

166 

167 All of `n_params_per_layer`, `n_pulse_params_per_layer`, 

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

169 """ 

170 

171 @classmethod 

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

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

174 raise NotImplementedError 

175 

176 @classmethod 

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

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

179 

180 @classmethod 

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

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

183 

184 @classmethod 

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

186 """ 

187 Computes parameter indices for controlled rotation Gates. 

188 Scans the structure for Block with 

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

190 """ 

191 structure = cls.structure() 

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

193 

194 # Collect which parameter indices correspond to controlled rotations 

195 controlled_indices = [] 

196 offset = 0 

197 for block in structure: 

198 n = block.n_params(n_qubits) 

199 if block.is_controlled_rotation: 

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

201 offset += n 

202 

203 # FIXME: this last part should be reworked 

204 

205 if not controlled_indices: 

206 return None 

207 

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

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

210 if controlled_indices == list( 

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

212 ): 

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

214 

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

216 return controlled_indices 

217 

218 @classmethod 

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

220 structure = cls.structure() 

221 w_idx = 0 

222 for block in structure: 

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

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

225 

226 

227class Block: 

228 def __init__( 

229 self, 

230 gate: str, 

231 topology: Any = None, 

232 shared: bool = False, 

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

234 **kwargs, 

235 ): 

236 """ 

237 Initialize a Block object; the atoms of Ansatzes. 

238 

239 Args: 

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

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

242 Defaults to None. 

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

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

245 Defaults to False. 

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

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

248 Defaults to None. 

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

250 """ 

251 if isinstance(gate, str): 

252 self.gate = getattr(Gates, gate) 

253 else: 

254 self.gate = gate 

255 

256 if self.is_entangling: 

257 assert topology is not None, ( 

258 "Topology must be specified for entangling gates" 

259 ) 

260 

261 self.topology = topology 

262 self.shared = shared 

263 self.wires = wires 

264 self.kwargs = kwargs 

265 

266 def __repr__(self): 

267 if self.topology is None: 

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

269 else: 

270 return ( 

271 f"{self.__class__.__name__}" 

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

273 ) 

274 

275 @property 

276 def is_entangling(self): 

277 return Gates.is_entangling(self.gate) 

278 

279 @property 

280 def is_rotational(self): 

281 return Gates.is_rotational(self.gate) 

282 

283 @property 

284 def is_controlled_rotation(self): 

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

286 

287 def enough_qubits(self, n_qubits): 

288 if self.is_entangling: 

289 # NOTE This must be adjusted if default values 

290 # in Topology change 

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

292 if callable(span): 

293 span = span(n_qubits) 

294 

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

296 

297 return n_qubits >= 1 

298 

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

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

301 

302 if not self.is_rotational: 

303 return 0 

304 

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

306 

307 if self.is_entangling: 

308 if not self.enough_qubits(n_qubits): 

309 warnings.warn( 

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

311 f"as there are not enough qubits" 

312 f"for this topology." 

313 ) 

314 return 0 

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

316 else: 

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

318 

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

320 return 0 

321 

322 return per_gate if self.shared else per_gate * n_gates 

323 

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

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

326 

327 n_pulse_params = PulseInformation.num_params(self.gate) 

328 if self.is_entangling: 

329 if not self.enough_qubits(n_qubits): 

330 warnings.warn( 

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

332 f"as there are not enough qubits" 

333 f"for this topology." 

334 ) 

335 return 0 

336 else: 

337 return n_pulse_params * len( 

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

339 ) 

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

341 return n_pulse_params * n_gates 

342 

343 def apply( 

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

345 ) -> int: 

346 """ 

347 Applies the block to the given circuit. 

348 

349 Args: 

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

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

352 Defaults to None. 

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

354 Defaults to None. 

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

356 

357 Returns: 

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

359 """ 

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

361 

362 if self.is_entangling: 

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

364 else: 

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

366 

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

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

369 applied = False 

370 

371 for wires in iterator: 

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

373 warnings.warn( 

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

375 f"as there are not enough qubits" 

376 f"for this topology." 

377 ) 

378 continue 

379 

380 if self.is_rotational: 

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

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

383 

384 i = base if self.shared else w_idx 

385 if per_gate == 3: 

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

387 else: 

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

389 if not self.shared: 

390 w_idx += per_gate 

391 applied = True 

392 else: 

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

394 

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

396 w_idx = base + per_gate 

397 return w_idx 

398 

399 

400class Ansaetze: 

401 def get_available(parameterized_only=False): 

402 # list of parameterized ansaetze 

403 ansaetze = [ 

404 Ansaetze.Circuit_1, 

405 Ansaetze.Circuit_2, 

406 Ansaetze.Circuit_3, 

407 Ansaetze.Circuit_4, 

408 Ansaetze.Circuit_5, 

409 Ansaetze.Circuit_6, 

410 Ansaetze.Circuit_7, 

411 Ansaetze.Circuit_8, 

412 Ansaetze.Circuit_9, 

413 Ansaetze.Circuit_10, 

414 Ansaetze.Circuit_13, 

415 Ansaetze.Circuit_14, 

416 Ansaetze.Circuit_15, 

417 Ansaetze.Circuit_16, 

418 Ansaetze.Circuit_17, 

419 Ansaetze.Circuit_18, 

420 Ansaetze.Circuit_19, 

421 Ansaetze.Circuit_20, 

422 Ansaetze.No_Entangling, 

423 Ansaetze.Strongly_Entangling, 

424 Ansaetze.Hardware_Efficient, 

425 Ansaetze.Permutation_Equivariant, 

426 Ansaetze.Matchgate, 

427 Ansaetze.XY_Brickwork, 

428 ] 

429 

430 # extend by the non-parameterized ones 

431 if not parameterized_only: 

432 ansaetze += [ 

433 Ansaetze.No_Ansatz, 

434 Ansaetze.GHZ, 

435 ] 

436 

437 return ansaetze 

438 

439 class No_Ansatz(DeclarativeCircuit): 

440 @classmethod 

441 def structure(cls): 

442 return () 

443 

444 class GHZ(DeclarativeCircuit): 

445 @classmethod 

446 def structure(cls): 

447 return ( 

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

449 Block( 

450 gate=Gates.CX, 

451 topology=Topology.stairs, 

452 reverse=False, 

453 mirror=False, 

454 ), 

455 ) 

456 

457 class Circuit_1(DeclarativeCircuit): 

458 @classmethod 

459 def structure(cls): 

460 return ( 

461 Block(gate=Gates.RX), 

462 Block(gate=Gates.RZ), 

463 ) 

464 

465 class Circuit_2(DeclarativeCircuit): 

466 @classmethod 

467 def structure(cls): 

468 return ( 

469 Block(gate=Gates.RX), 

470 Block(gate=Gates.RZ), 

471 Block( 

472 gate=Gates.CX, 

473 topology=Topology.stairs, 

474 ), 

475 ) 

476 

477 class Circuit_3(DeclarativeCircuit): 

478 @classmethod 

479 def structure(cls): 

480 return ( 

481 Block(gate=Gates.RX), 

482 Block(gate=Gates.RZ), 

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

484 ) 

485 

486 class Circuit_4(DeclarativeCircuit): 

487 @classmethod 

488 def structure(cls): 

489 return ( 

490 Block(gate=Gates.RX), 

491 Block(gate=Gates.RZ), 

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

493 ) 

494 

495 class Circuit_5(DeclarativeCircuit): 

496 @classmethod 

497 def structure(cls): 

498 return ( 

499 Block(gate=Gates.RX), 

500 Block(gate=Gates.RZ), 

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

502 Block(gate=Gates.RX), 

503 Block(gate=Gates.RZ), 

504 ) 

505 

506 class Circuit_6(DeclarativeCircuit): 

507 @classmethod 

508 def structure(cls): 

509 return ( 

510 Block(gate=Gates.RX), 

511 Block(gate=Gates.RZ), 

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

513 Block(gate=Gates.RX), 

514 Block(gate=Gates.RZ), 

515 ) 

516 

517 class Circuit_7(DeclarativeCircuit): 

518 @classmethod 

519 def structure(cls): 

520 return ( 

521 Block(gate=Gates.RX), 

522 Block(gate=Gates.RZ), 

523 Block( 

524 gate=Gates.CRZ, 

525 topology=Topology.bricks, 

526 ), 

527 Block(gate=Gates.RX), 

528 Block(gate=Gates.RZ), 

529 Block( 

530 gate=Gates.CRZ, 

531 topology=Topology.bricks, 

532 offset=1, 

533 ), 

534 ) 

535 

536 class Circuit_8(DeclarativeCircuit): 

537 @classmethod 

538 def structure(cls): 

539 return ( 

540 Block(gate=Gates.RX), 

541 Block(gate=Gates.RZ), 

542 Block( 

543 gate=Gates.CRX, 

544 topology=Topology.bricks, 

545 ), 

546 Block(gate=Gates.RX), 

547 Block(gate=Gates.RZ), 

548 Block( 

549 gate=Gates.CRX, 

550 topology=Topology.bricks, 

551 offset=1, 

552 ), 

553 ) 

554 

555 class Circuit_9(DeclarativeCircuit): 

556 @classmethod 

557 def structure(cls): 

558 return ( 

559 Block(gate=Gates.H), 

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

561 Block(gate=Gates.RX), 

562 ) 

563 

564 class Circuit_10(DeclarativeCircuit): 

565 @classmethod 

566 def structure(cls): 

567 return ( 

568 Block(gate=Gates.RY), 

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

570 Block(gate=Gates.RY), 

571 ) 

572 

573 class Circuit_13(DeclarativeCircuit): 

574 @classmethod 

575 def structure(cls): 

576 return ( 

577 Block(gate=Gates.RY), 

578 Block( 

579 gate=Gates.CRZ, 

580 topology=Topology.stairs, 

581 wrap=True, 

582 reverse=True, 

583 mirror=False, 

584 ), 

585 Block(gate=Gates.RY), 

586 Block( 

587 gate=Gates.CRZ, 

588 topology=Topology.stairs, 

589 reverse=False, 

590 mirror=False, 

591 offset=lambda n: n - 1, 

592 span=3, 

593 wrap=True, 

594 ), 

595 ) 

596 

597 class Circuit_14(DeclarativeCircuit): 

598 @classmethod 

599 def structure(cls): 

600 return ( 

601 Block(gate=Gates.RY), 

602 Block( 

603 gate=Gates.CRX, 

604 topology=Topology.stairs, 

605 wrap=True, 

606 reverse=True, 

607 mirror=False, 

608 ), 

609 Block(gate=Gates.RY), 

610 Block( 

611 gate=Gates.CRX, 

612 topology=Topology.stairs, 

613 reverse=False, 

614 mirror=False, 

615 offset=lambda n: n - 1, 

616 span=3, 

617 wrap=True, 

618 ), 

619 ) 

620 

621 class Circuit_15(DeclarativeCircuit): 

622 @classmethod 

623 def structure(cls): 

624 return ( 

625 Block(gate=Gates.RY), 

626 Block( 

627 gate=Gates.CX, 

628 topology=Topology.stairs, 

629 wrap=True, 

630 reverse=True, 

631 mirror=False, 

632 ), 

633 Block(gate=Gates.RY), 

634 Block( 

635 gate=Gates.CX, 

636 topology=Topology.stairs, 

637 reverse=False, 

638 mirror=False, 

639 offset=lambda n: n - 1, 

640 span=3, 

641 wrap=True, 

642 ), 

643 ) 

644 

645 class Circuit_16(DeclarativeCircuit): 

646 @classmethod 

647 def structure(cls): 

648 return ( 

649 Block(gate=Gates.RX), 

650 Block(gate=Gates.RZ), 

651 Block( 

652 gate=Gates.CRZ, 

653 topology=Topology.bricks, 

654 ), 

655 Block( 

656 gate=Gates.CRZ, 

657 topology=Topology.bricks, 

658 offset=1, 

659 ), 

660 ) 

661 

662 class Circuit_17(DeclarativeCircuit): 

663 @classmethod 

664 def structure(cls): 

665 return ( 

666 Block(gate=Gates.RX), 

667 Block(gate=Gates.RZ), 

668 Block( 

669 gate=Gates.CRX, 

670 topology=Topology.bricks, 

671 ), 

672 Block( 

673 gate=Gates.CRX, 

674 topology=Topology.bricks, 

675 offset=1, 

676 ), 

677 ) 

678 

679 class Circuit_18(DeclarativeCircuit): 

680 @classmethod 

681 def structure(cls): 

682 return ( 

683 Block(gate=Gates.RX), 

684 Block(gate=Gates.RZ), 

685 Block( 

686 gate=Gates.CRZ, 

687 topology=Topology.stairs, 

688 wrap=True, 

689 mirror=False, 

690 ), 

691 ) 

692 

693 class Circuit_19(DeclarativeCircuit): 

694 @classmethod 

695 def structure(cls): 

696 return ( 

697 Block(gate=Gates.RX), 

698 Block(gate=Gates.RZ), 

699 Block( 

700 gate=Gates.CRX, 

701 topology=Topology.stairs, 

702 wrap=True, 

703 mirror=False, 

704 ), 

705 ) 

706 

707 class Circuit_20(DeclarativeCircuit): 

708 @classmethod 

709 def structure(cls): 

710 return ( 

711 Block(gate=Gates.RY), 

712 Block( 

713 gate=Gates.CX, 

714 topology=Topology.stairs, 

715 wrap=True, 

716 reverse=True, 

717 mirror=False, 

718 ), 

719 Block(gate=Gates.RY), 

720 Block( 

721 gate=Gates.CX, 

722 topology=Topology.stairs, 

723 reverse=False, 

724 offset=lambda n: n - 2, 

725 span=1, 

726 wrap=True, 

727 ), 

728 ) 

729 

730 class No_Entangling(DeclarativeCircuit): 

731 @classmethod 

732 def structure(cls): 

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

734 

735 class Hardware_Efficient(DeclarativeCircuit): 

736 @classmethod 

737 def structure(cls): 

738 return ( 

739 Block(gate=Gates.RY), 

740 Block(gate=Gates.RZ), 

741 Block(gate=Gates.RY), 

742 Block( 

743 gate=Gates.CX, 

744 topology=Topology.bricks, 

745 mirror=False, 

746 ), 

747 Block( 

748 gate=Gates.CX, 

749 topology=Topology.bricks, 

750 offset=-1, 

751 modulo=True, 

752 wrap=True, 

753 mirror=False, 

754 ), 

755 ) 

756 

757 class Strongly_Entangling(DeclarativeCircuit): 

758 @classmethod 

759 def structure(cls): 

760 return ( 

761 Block(gate=Gates.Rot), 

762 Block( 

763 gate=Gates.CX, 

764 topology=Topology.stairs, 

765 wrap=True, 

766 reverse=False, 

767 mirror=False, 

768 ), 

769 Block(gate=Gates.Rot), 

770 Block( 

771 gate=Gates.CX, 

772 topology=Topology.stairs, 

773 reverse=False, 

774 span=lambda n: n // 2, 

775 wrap=True, 

776 mirror=False, 

777 ), 

778 ) 

779 

780 class Permutation_Equivariant(DeclarativeCircuit): 

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

782 

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

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

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

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

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

788 width is 3 independent of the qubit count. 

789 

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

791 special handling for the shared parameters. 

792 """ 

793 

794 @classmethod 

795 def structure(cls): 

796 return ( 

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

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

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

800 ) 

801 

802 class Matchgate(DeclarativeCircuit): 

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

804 

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

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

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

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

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

810 """ 

811 

812 @classmethod 

813 def structure(cls): 

814 return ( 

815 Block(gate=Gates.RZ), 

816 Block( 

817 gate=Gates.RXX, 

818 topology=Topology.bricks, 

819 offset=0, 

820 reverse=False, 

821 mirror=False, 

822 ), 

823 Block( 

824 gate=Gates.RXX, 

825 topology=Topology.bricks, 

826 offset=1, 

827 reverse=False, 

828 mirror=False, 

829 ), 

830 ) 

831 

832 class XY_Brickwork(DeclarativeCircuit): 

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

834 

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

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

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

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

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

840 """ 

841 

842 @classmethod 

843 def structure(cls): 

844 return ( 

845 Block( 

846 gate=Gates.RXX, 

847 topology=Topology.bricks, 

848 offset=0, 

849 reverse=False, 

850 mirror=False, 

851 ), 

852 Block( 

853 gate=Gates.RXX, 

854 topology=Topology.bricks, 

855 offset=1, 

856 reverse=False, 

857 mirror=False, 

858 ), 

859 Block( 

860 gate=Gates.RYY, 

861 topology=Topology.bricks, 

862 offset=0, 

863 reverse=False, 

864 mirror=False, 

865 ), 

866 Block( 

867 gate=Gates.RYY, 

868 topology=Topology.bricks, 

869 offset=1, 

870 reverse=False, 

871 mirror=False, 

872 ), 

873 ) 

874 

875 

876# Cache for computed rulers 

877_GOLOMB_RULER_CACHE: dict = {} 

878 

879 

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

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

882 

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

884 whose pairwise differences with all existing marks are distinct. 

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

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

887 

888 Args: 

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

890 

891 Returns: 

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

893 """ 

894 if d <= 0: 

895 return () 

896 marks = [0] 

897 diffs: set = set() 

898 candidate = 1 

899 while len(marks) < d: 

900 new_diffs: set = set() 

901 valid = True 

902 for existing in marks: 

903 diff = candidate - existing 

904 if diff in diffs or diff in new_diffs: 

905 valid = False 

906 break 

907 new_diffs.add(diff) 

908 if valid: 

909 marks.append(candidate) 

910 diffs |= new_diffs 

911 candidate += 1 

912 return tuple(marks) 

913 

914 

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

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

917 

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

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

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

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

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

923 for any *d*-dimensional Hamiltonian. 

924 

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

926 Results are cached for efficiency. 

927 

928 Args: 

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

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

931 

932 Returns: 

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

934 

935 Raises: 

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

937 

938 References: 

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

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

941 """ 

942 if d <= 0: 

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

944 if d not in _GOLOMB_RULER_CACHE: 

945 _GOLOMB_RULER_CACHE[d] = _greedy_golomb(d) 

946 return _GOLOMB_RULER_CACHE[d] 

947 

948 

949def GolombEncoding( 

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

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

952 noise_params: Optional[dict] = None, 

953 random_key: Optional[Any] = None, 

954 **kwargs: Any, 

955) -> None: 

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

957 

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

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

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

961 maximally non-degenerate Fourier spectrum with 

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

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

964 

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

966 

967 Args: 

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

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

970 acted upon simultaneously via a single multi-qubit diagonal 

971 gate. 

972 noise_params: Optional noise parameters dictionary. 

973 random_key: JAX random key for stochastic noise. 

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

975 signature of the per-qubit encoding gates. 

976 

977 Returns: 

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

979 

980 Raises: 

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

982 encoding has no pulse parametrization for. 

983 """ 

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

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

986 

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

988 d = 2 ** len(wires_list) 

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

990 

991 # Apply gate error to the input angle 

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

993 

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

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

996 

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

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

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

1000 UnitaryGates.Noise(wires_list, noise_params) 

1001 

1002 

1003class Encoding: 

1004 def __init__( 

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

1006 ): 

1007 """ 

1008 Initializes an Encoding object. 

1009 

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

1011 

1012 Parameters 

1013 ---------- 

1014 strategy : str 

1015 The encoding strategy to use. Available options: 

1016 ['hamming', 'binary', 'ternary'] 

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

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

1019 of strings or callables. 

1020 

1021 Returns 

1022 ------- 

1023 None 

1024 

1025 Raises 

1026 ------- 

1027 ValueError 

1028 If the encoding strategy is not implemented. 

1029 ValueError 

1030 If there is an error parsing the Gates. 

1031 """ 

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

1033 raise ValueError( 

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

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

1036 ) 

1037 self._strategy = strategy 

1038 strategy_fn = getattr(self, strategy) 

1039 

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

1041 

1042 if self._strategy == "golomb": 

1043 self._gates = [] 

1044 self.callable = [strategy_fn(None)] 

1045 else: 

1046 try: 

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

1048 except ValueError as e: 

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

1050 

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

1052 

1053 def __len__(self): 

1054 return len(self.callable) 

1055 

1056 def __getitem__(self, idx): 

1057 return self.callable[idx] 

1058 

1059 def get_n_freqs(self, data_reupload): 

1060 """ 

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

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

1063 """ 

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

1065 

1066 def get_spectrum(self, data_reupload): 

1067 """ 

1068 Reachable Fourier frequency comb for the encoding strategy. 

1069 

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

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

1072 

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

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

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

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

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

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

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

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

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

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

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

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

1085 carry ~0 coefficients. 

1086 

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

1088 

1089 Parameters 

1090 ---------- 

1091 data_reupload : np.ndarray 

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

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

1094 

1095 Returns 

1096 ------- 

1097 np.ndarray 

1098 The sorted reachable spectrum of the encoding strategy. 

1099 """ 

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

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

1102 mask = mask[None, :] 

1103 

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

1105 raise NotImplementedError 

1106 if self._strategy == "golomb": 

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

1108 if n_qubits is None: 

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

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

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

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

1113 

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

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

1116 reach = {0} 

1117 for q, c in enumerate(counts): 

1118 scale = base**q 

1119 reach = { 

1120 a + k 

1121 for a in reach 

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

1123 } 

1124 return np.array(sorted(reach)) 

1125 

1126 def get_weights(self, n_qubits): 

1127 """ 

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

1129 

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

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

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

1133 

1134 Parameters 

1135 ---------- 

1136 n_qubits : int 

1137 The number of qubits carrying the encoding. 

1138 

1139 Returns 

1140 ------- 

1141 np.ndarray 

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

1143 

1144 Raises 

1145 ------ 

1146 ValueError 

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

1148 """ 

1149 if self._strategy == "hamming": 

1150 return np.ones(n_qubits) 

1151 elif self._strategy == "binary": 

1152 return 2.0 ** np.arange(n_qubits) 

1153 elif self._strategy == "ternary": 

1154 return 3.0 ** np.arange(n_qubits) 

1155 elif self._strategy == "golomb": 

1156 raise ValueError( 

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

1158 ) 

1159 else: 

1160 raise NotImplementedError 

1161 

1162 def hamming(self, enc): 

1163 """ 

1164 Hamming encoding strategy. 

1165 

1166 Returns an encoding function that uses the Hamming encoding strategy 

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

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

1169 

1170 Parameters 

1171 ---------- 

1172 enc : Callable 

1173 The encoding function to be wrapped. 

1174 

1175 Returns 

1176 ------- 

1177 Callable 

1178 The wrapped encoding function. 

1179 """ 

1180 return enc 

1181 

1182 def binary(self, enc): 

1183 """ 

1184 Binary encoding strategy. 

1185 

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

1187 

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

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

1190 

1191 Parameters 

1192 ---------- 

1193 enc : Callable 

1194 The encoding function to be wrapped. 

1195 

1196 Returns 

1197 ------- 

1198 Callable 

1199 The wrapped encoding function. 

1200 """ 

1201 

1202 def _enc(inputs, wires, **kwargs): 

1203 return enc(inputs * (2**wires), wires, **kwargs) 

1204 

1205 return _enc 

1206 

1207 def ternary(self, enc): 

1208 """ 

1209 Ternary encoding strategy. 

1210 

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

1212 

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

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

1215 

1216 Parameters 

1217 ---------- 

1218 enc : Callable 

1219 The encoding function to be wrapped. 

1220 

1221 Returns 

1222 ------- 

1223 Callable 

1224 The wrapped encoding function. 

1225 """ 

1226 

1227 def _enc(inputs, wires, **kwargs): 

1228 return enc(inputs * (3**wires), wires, **kwargs) 

1229 

1230 return _enc 

1231 

1232 @property 

1233 def is_golomb(self): 

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

1235 return self._strategy == "golomb" 

1236 

1237 def golomb(self, enc): 

1238 """Golomb encoding strategy. 

1239 

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

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

1242 qubits simultaneously. This produces the largest possible 

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

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

1245 

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

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

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

1249 all qubits. 

1250 

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

1252 

1253 Parameters 

1254 ---------- 

1255 enc : Callable or None 

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

1257 

1258 Returns 

1259 ------- 

1260 Callable 

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

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

1263 """ 

1264 

1265 def _enc(inputs, wires, **kwargs): 

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

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

1268 

1269 return _enc