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
« 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
8from jaqsi.gates import Gates, PulseInformation
9from jaqsi.gateset import DiagonalQubitUnitary
10from jaqsi.unitary import UnitaryGates
12from qml_essentials.topologies import Topology
14log = logging.getLogger(__name__)
17class Circuit(ABC):
18 """Abstract base class for quantum circuit ansätze."""
20 def __init__(self) -> None:
21 """Initialize the circuit."""
22 pass
24 @abstractmethod
25 def n_params_per_layer(self, n_qubits: int) -> int:
26 """
27 Get the number of parameters per circuit layer.
29 Args:
30 n_qubits (int): Number of qubits in the circuit.
32 Returns:
33 int: Number of parameters required per layer.
35 Raises:
36 NotImplementedError: Must be implemented by subclasses.
37 """
38 raise NotImplementedError("n_params_per_layer method is not implemented")
40 def n_pulse_params_per_layer(self, n_qubits: int) -> int:
41 """
42 Get the number of pulse parameters per circuit layer.
44 Subclasses that do not use pulse-level simulation do not need to
45 override this method.
47 Args:
48 n_qubits (int): Number of qubits in the circuit.
50 Returns:
51 int: Number of pulse parameters required per layer.
53 Raises:
54 NotImplementedError: If called but not overridden by subclass.
55 """
56 raise NotImplementedError("n_pulse_params_per_layer method is not implemented")
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.
63 Returns slice indices [start:stop:step] for extracting controlled
64 gate parameters from a full parameter array for one layer.
66 Args:
67 n_qubits (int): Number of qubits in the circuit.
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.
74 Raises:
75 NotImplementedError: Must be implemented by subclasses.
76 """
77 raise NotImplementedError("get_control_indices method is not implemented")
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.
83 Args:
84 w (np.ndarray): Parameter array for one layer.
85 n_qubits (int): Number of qubits in the circuit.
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([])
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))
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.
104 Internal method that handles pulse parameter validation and context
105 management before delegating to the build() method.
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.
116 Returns:
117 Any: Result from the build() method.
119 Raises:
120 ValueError: If pulse_params length doesn't match expected count.
121 """
122 pulse = kwargs.get("pulse", False)
124 if pulse and "pulse_params" in kwargs:
125 pulse_params_per_layer = self.n_pulse_params_per_layer(n_qubits)
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 )
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)
139 @abstractmethod
140 def build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any:
141 """
142 Build one layer of the quantum circuit.
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.
149 Returns:
150 Any: Circuit construction result.
152 Raises:
153 NotImplementedError: Must be implemented by subclasses.
154 """
155 raise NotImplementedError("build method is not implemented")
157 def __call__(self, *args: Any, **kwds: Any) -> Any:
158 """Call the _build method with provided arguments."""
159 self._build(*args, **kwds)
162class DeclarativeCircuit(Circuit):
163 """
164 A circuit defined entirely by a sequence of Block descriptors.
166 Subclasses only need to set the class attribute `structure` — a tuple of
168 All of `n_params_per_layer`, `n_pulse_params_per_layer`,
169 `get_control_indices`, and `build` are derived automatically.
170 """
172 @classmethod
173 def structure(cls) -> Tuple[Any, ...]:
174 """Override in subclass to return the structure tuple."""
175 raise NotImplementedError
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())
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())
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)
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
204 # FIXME: this last part should be reworked
206 if not controlled_indices:
207 return None
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]
216 # Fallback: return raw indices (future-proof)
217 return controlled_indices
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)
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.
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
257 if self.is_entangling:
258 assert topology is not None, (
259 "Topology must be specified for entangling gates"
260 )
262 self.topology = topology
263 self.shared = shared
264 self.wires = wires
265 self.kwargs = kwargs
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 )
276 @property
277 def is_entangling(self):
278 return Gates.is_entangling(self.gate)
280 @property
281 def is_rotational(self):
282 return Gates.is_rotational(self.gate)
284 @property
285 def is_controlled_rotation(self):
286 return Gates.is_controlled(self.gate) and self.is_rotational
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)
296 return (n_qubits >= 2) and (n_qubits > span)
298 return n_qubits >= 1
300 def n_params(self, n_qubits: int) -> int:
301 assert n_qubits > 0, "Number of qubits must be positive"
303 if not self.is_rotational:
304 return 0
306 per_gate = 3 if self.gate.__name__ == "Rot" else 1
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
320 if n_gates == 0: # an empty block consumes no parameters, shared or not
321 return 0
323 return per_gate if self.shared else per_gate * n_gates
325 def n_pulse_params(self, n_qubits: int) -> int:
326 assert n_qubits > 0, "Number of qubits must be positive"
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
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.
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.
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"
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)
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
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
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"
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)
396 if self.is_rotational and self.shared and applied:
397 w_idx = base + per_gate
398 return w_idx
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 ]
431 # extend by the non-parameterized ones
432 if not parameterized_only:
433 ansaetze += [
434 Ansaetze.No_Ansatz,
435 Ansaetze.GHZ,
436 ]
438 return ansaetze
440 class No_Ansatz(DeclarativeCircuit):
441 @classmethod
442 def structure(cls):
443 return ()
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 )
458 class Circuit_1(DeclarativeCircuit):
459 @classmethod
460 def structure(cls):
461 return (
462 Block(gate=Gates.RX),
463 Block(gate=Gates.RZ),
464 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
731 class No_Entangling(DeclarativeCircuit):
732 @classmethod
733 def structure(cls):
734 return (Block(gate=Gates.Rot),)
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 )
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 )
781 class Permutation_Equivariant(DeclarativeCircuit):
782 r"""$S_n$ permutation-equivariant layer (Schatzki et al., arXiv:2210.09974).
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.
791 Gradients assume JAX autodiff; a parameter-shift differentiator would need
792 special handling for the shared parameters.
793 """
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 )
803 class Matchgate(DeclarativeCircuit):
804 r"""Matchgate LASA layer: RZ on every qubit + nearest-neighbour RXX.
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 """
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 )
833 class XY_Brickwork(DeclarativeCircuit):
834 r"""Off-diagonal XY brickwork: nearest-neighbour RXX then RYY.
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 """
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 )
877# Cache for computed rulers
878_GOLOMB_RULER_CACHE: dict = {}
881def _greedy_golomb(d: int) -> Tuple[int, ...]:
882 """Construct a valid Golomb ruler of order *d* using a greedy algorithm.
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).
889 Args:
890 d: Order of the ruler (number of marks).
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)
916def golomb_ruler(d: int) -> Tuple[int, ...]:
917 """Return a valid Golomb ruler of order *d*.
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.
926 Uses a greedy construction that always produces a valid ruler.
927 Results are cached for efficiency.
929 Args:
930 d: Order of the ruler (number of marks, equal to the Hilbert
931 space dimension ``2^n_qubits``).
933 Returns:
934 Tuple of *d* non-negative integers forming a Golomb ruler.
936 Raises:
937 ValueError: If ``d <= 0``.
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]
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.
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``.
966 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4.
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.
978 Returns:
979 None: Gate and noise are applied in-place to the circuit.
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")
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)
992 # Apply gate error to the input angle
993 w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
995 # Build diagonal: exp(-i * mark_j * x)
996 diag = np.exp(-1j * marks * w)
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)
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)
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)
1016class Encoding:
1017 def __init__(
1018 self, strategy: str, gates: Union[str, Callable, List[Union[str, Callable]]]
1019 ):
1020 """
1021 Initializes an Encoding object.
1023 Implementations closely follow https://doi.org/10.22331/q-2023-12-20-1210
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.
1034 Returns
1035 -------
1036 None
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)
1053 log.debug(f"Using encoding strategy: '{strategy_fn.__name__}'")
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}")
1064 self.callable = [strategy_fn(g) for g in self._gates]
1066 def __len__(self):
1067 return len(self.callable)
1069 def __getitem__(self, idx):
1070 return self.callable[idx]
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)
1079 def get_spectrum(self, data_reupload):
1080 """
1081 Reachable Fourier frequency comb for the encoding strategy.
1083 Computed exactly from the ``(n_layers, n_qubits)`` data-reupload mask as
1084 the Minkowski sum of the per-gate generator frequencies:
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.
1100 See https://doi.org/10.22331/q-2023-12-20-1210 for more details.
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.
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, :]
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)
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))
1139 def get_weights(self, n_qubits):
1140 """
1141 Per-qubit weight vector w for the separable weighted encodings.
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`).
1147 Parameters
1148 ----------
1149 n_qubits : int
1150 The number of qubits carrying the encoding.
1152 Returns
1153 -------
1154 np.ndarray
1155 The weight vector of shape ``(n_qubits,)``.
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
1175 def hamming(self, enc):
1176 """
1177 Hamming encoding strategy.
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.
1183 Parameters
1184 ----------
1185 enc : Callable
1186 The encoding function to be wrapped.
1188 Returns
1189 -------
1190 Callable
1191 The wrapped encoding function.
1192 """
1193 return enc
1195 def binary(self, enc):
1196 """
1197 Binary encoding strategy.
1199 Returns an encoding function that scales the input by a factor of 2^wires.
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.
1204 Parameters
1205 ----------
1206 enc : Callable
1207 The encoding function to be wrapped.
1209 Returns
1210 -------
1211 Callable
1212 The wrapped encoding function.
1213 """
1215 return functools.partial(_scaled_encoding, enc=enc, base=2)
1217 def ternary(self, enc):
1218 """
1219 Ternary encoding strategy.
1221 Returns an encoding function that scales the input by a factor of 3^wires.
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.
1226 Parameters
1227 ----------
1228 enc : Callable
1229 The encoding function to be wrapped.
1231 Returns
1232 -------
1233 Callable
1234 The wrapped encoding function.
1235 """
1237 return functools.partial(_scaled_encoding, enc=enc, base=3)
1239 @property
1240 def is_golomb(self):
1241 """Whether this encoding uses the Golomb (multi-qubit diagonal) strategy."""
1242 return self._strategy == "golomb"
1244 def golomb(self, enc):
1245 """Golomb encoding strategy.
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*.
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.
1258 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4.
1260 Parameters
1261 ----------
1262 enc : Callable or None
1263 Ignored (Golomb encoding uses its own multi-qubit gate).
1265 Returns
1266 -------
1267 Callable
1268 A callable with the same signature as per-qubit encoding
1269 functions but that applies :func:`GolombEncoding`.
1270 """
1272 return _golomb_encoding