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
« 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
7from jaqsi.gates import Gates, PulseInformation
8from jaqsi.gateset import DiagonalQubitUnitary
9from jaqsi.unitary import UnitaryGates
11from qml_essentials.topologies import Topology
13log = logging.getLogger(__name__)
16class Circuit(ABC):
17 """Abstract base class for quantum circuit ansätze."""
19 def __init__(self) -> None:
20 """Initialize the circuit."""
21 pass
23 @abstractmethod
24 def n_params_per_layer(self, n_qubits: int) -> int:
25 """
26 Get the number of parameters per circuit layer.
28 Args:
29 n_qubits (int): Number of qubits in the circuit.
31 Returns:
32 int: Number of parameters required per layer.
34 Raises:
35 NotImplementedError: Must be implemented by subclasses.
36 """
37 raise NotImplementedError("n_params_per_layer method is not implemented")
39 def n_pulse_params_per_layer(self, n_qubits: int) -> int:
40 """
41 Get the number of pulse parameters per circuit layer.
43 Subclasses that do not use pulse-level simulation do not need to
44 override this method.
46 Args:
47 n_qubits (int): Number of qubits in the circuit.
49 Returns:
50 int: Number of pulse parameters required per layer.
52 Raises:
53 NotImplementedError: If called but not overridden by subclass.
54 """
55 raise NotImplementedError("n_pulse_params_per_layer method is not implemented")
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.
62 Returns slice indices [start:stop:step] for extracting controlled
63 gate parameters from a full parameter array for one layer.
65 Args:
66 n_qubits (int): Number of qubits in the circuit.
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.
73 Raises:
74 NotImplementedError: Must be implemented by subclasses.
75 """
76 raise NotImplementedError("get_control_indices method is not implemented")
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.
82 Args:
83 w (np.ndarray): Parameter array for one layer.
84 n_qubits (int): Number of qubits in the circuit.
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([])
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))
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.
103 Internal method that handles pulse parameter validation and context
104 management before delegating to the build() method.
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.
115 Returns:
116 Any: Result from the build() method.
118 Raises:
119 ValueError: If pulse_params length doesn't match expected count.
120 """
121 pulse = kwargs.get("pulse", False)
123 if pulse and "pulse_params" in kwargs:
124 pulse_params_per_layer = self.n_pulse_params_per_layer(n_qubits)
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 )
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)
138 @abstractmethod
139 def build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any:
140 """
141 Build one layer of the quantum circuit.
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.
148 Returns:
149 Any: Circuit construction result.
151 Raises:
152 NotImplementedError: Must be implemented by subclasses.
153 """
154 raise NotImplementedError("build method is not implemented")
156 def __call__(self, *args: Any, **kwds: Any) -> Any:
157 """Call the _build method with provided arguments."""
158 self._build(*args, **kwds)
161class DeclarativeCircuit(Circuit):
162 """
163 A circuit defined entirely by a sequence of Block descriptors.
165 Subclasses only need to set the class attribute `structure` — a tuple of
167 All of `n_params_per_layer`, `n_pulse_params_per_layer`,
168 `get_control_indices`, and `build` are derived automatically.
169 """
171 @classmethod
172 def structure(cls) -> Tuple[Any, ...]:
173 """Override in subclass to return the structure tuple."""
174 raise NotImplementedError
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())
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())
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)
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
203 # FIXME: this last part should be reworked
205 if not controlled_indices:
206 return None
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]
215 # Fallback: return raw indices (future-proof)
216 return controlled_indices
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)
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.
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
256 if self.is_entangling:
257 assert topology is not None, (
258 "Topology must be specified for entangling gates"
259 )
261 self.topology = topology
262 self.shared = shared
263 self.wires = wires
264 self.kwargs = kwargs
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 )
275 @property
276 def is_entangling(self):
277 return Gates.is_entangling(self.gate)
279 @property
280 def is_rotational(self):
281 return Gates.is_rotational(self.gate)
283 @property
284 def is_controlled_rotation(self):
285 return Gates.is_controlled(self.gate) and self.is_rotational
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)
295 return (n_qubits >= 2) and (n_qubits > span)
297 return n_qubits >= 1
299 def n_params(self, n_qubits: int) -> int:
300 assert n_qubits > 0, "Number of qubits must be positive"
302 if not self.is_rotational:
303 return 0
305 per_gate = 3 if self.gate.__name__ == "Rot" else 1
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
319 if n_gates == 0: # an empty block consumes no parameters, shared or not
320 return 0
322 return per_gate if self.shared else per_gate * n_gates
324 def n_pulse_params(self, n_qubits: int) -> int:
325 assert n_qubits > 0, "Number of qubits must be positive"
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
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.
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.
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"
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)
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
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
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"
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)
395 if self.is_rotational and self.shared and applied:
396 w_idx = base + per_gate
397 return w_idx
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 ]
430 # extend by the non-parameterized ones
431 if not parameterized_only:
432 ansaetze += [
433 Ansaetze.No_Ansatz,
434 Ansaetze.GHZ,
435 ]
437 return ansaetze
439 class No_Ansatz(DeclarativeCircuit):
440 @classmethod
441 def structure(cls):
442 return ()
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 )
457 class Circuit_1(DeclarativeCircuit):
458 @classmethod
459 def structure(cls):
460 return (
461 Block(gate=Gates.RX),
462 Block(gate=Gates.RZ),
463 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
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 )
730 class No_Entangling(DeclarativeCircuit):
731 @classmethod
732 def structure(cls):
733 return (Block(gate=Gates.Rot),)
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 )
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 )
780 class Permutation_Equivariant(DeclarativeCircuit):
781 r"""$S_n$ permutation-equivariant layer (Schatzki et al., arXiv:2210.09974).
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.
790 Gradients assume JAX autodiff; a parameter-shift differentiator would need
791 special handling for the shared parameters.
792 """
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 )
802 class Matchgate(DeclarativeCircuit):
803 r"""Matchgate LASA layer: RZ on every qubit + nearest-neighbour RXX.
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 """
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 )
832 class XY_Brickwork(DeclarativeCircuit):
833 r"""Off-diagonal XY brickwork: nearest-neighbour RXX then RYY.
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 """
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 )
876# Cache for computed rulers
877_GOLOMB_RULER_CACHE: dict = {}
880def _greedy_golomb(d: int) -> Tuple[int, ...]:
881 """Construct a valid Golomb ruler of order *d* using a greedy algorithm.
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).
888 Args:
889 d: Order of the ruler (number of marks).
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)
915def golomb_ruler(d: int) -> Tuple[int, ...]:
916 """Return a valid Golomb ruler of order *d*.
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.
925 Uses a greedy construction that always produces a valid ruler.
926 Results are cached for efficiency.
928 Args:
929 d: Order of the ruler (number of marks, equal to the Hilbert
930 space dimension ``2^n_qubits``).
932 Returns:
933 Tuple of *d* non-negative integers forming a Golomb ruler.
935 Raises:
936 ValueError: If ``d <= 0``.
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]
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.
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``.
965 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4.
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.
977 Returns:
978 None: Gate and noise are applied in-place to the circuit.
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")
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)
991 # Apply gate error to the input angle
992 w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
994 # Build diagonal: exp(-i * mark_j * x)
995 diag = np.exp(-1j * marks * w)
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)
1003class Encoding:
1004 def __init__(
1005 self, strategy: str, gates: Union[str, Callable, List[Union[str, Callable]]]
1006 ):
1007 """
1008 Initializes an Encoding object.
1010 Implementations closely follow https://doi.org/10.22331/q-2023-12-20-1210
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.
1021 Returns
1022 -------
1023 None
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)
1040 log.debug(f"Using encoding strategy: '{strategy_fn.__name__}'")
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}")
1051 self.callable = [strategy_fn(g) for g in self._gates]
1053 def __len__(self):
1054 return len(self.callable)
1056 def __getitem__(self, idx):
1057 return self.callable[idx]
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)
1066 def get_spectrum(self, data_reupload):
1067 """
1068 Reachable Fourier frequency comb for the encoding strategy.
1070 Computed exactly from the ``(n_layers, n_qubits)`` data-reupload mask as
1071 the Minkowski sum of the per-gate generator frequencies:
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.
1087 See https://doi.org/10.22331/q-2023-12-20-1210 for more details.
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.
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, :]
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)
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))
1126 def get_weights(self, n_qubits):
1127 """
1128 Per-qubit weight vector w for the separable weighted encodings.
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`).
1134 Parameters
1135 ----------
1136 n_qubits : int
1137 The number of qubits carrying the encoding.
1139 Returns
1140 -------
1141 np.ndarray
1142 The weight vector of shape ``(n_qubits,)``.
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
1162 def hamming(self, enc):
1163 """
1164 Hamming encoding strategy.
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.
1170 Parameters
1171 ----------
1172 enc : Callable
1173 The encoding function to be wrapped.
1175 Returns
1176 -------
1177 Callable
1178 The wrapped encoding function.
1179 """
1180 return enc
1182 def binary(self, enc):
1183 """
1184 Binary encoding strategy.
1186 Returns an encoding function that scales the input by a factor of 2^wires.
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.
1191 Parameters
1192 ----------
1193 enc : Callable
1194 The encoding function to be wrapped.
1196 Returns
1197 -------
1198 Callable
1199 The wrapped encoding function.
1200 """
1202 def _enc(inputs, wires, **kwargs):
1203 return enc(inputs * (2**wires), wires, **kwargs)
1205 return _enc
1207 def ternary(self, enc):
1208 """
1209 Ternary encoding strategy.
1211 Returns an encoding function that scales the input by a factor of 3^wires.
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.
1216 Parameters
1217 ----------
1218 enc : Callable
1219 The encoding function to be wrapped.
1221 Returns
1222 -------
1223 Callable
1224 The wrapped encoding function.
1225 """
1227 def _enc(inputs, wires, **kwargs):
1228 return enc(inputs * (3**wires), wires, **kwargs)
1230 return _enc
1232 @property
1233 def is_golomb(self):
1234 """Whether this encoding uses the Golomb (multi-qubit diagonal) strategy."""
1235 return self._strategy == "golomb"
1237 def golomb(self, enc):
1238 """Golomb encoding strategy.
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*.
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.
1251 See Peters et al., arXiv:2209.05523, Sec. 3.1 and Appendix C.4.
1253 Parameters
1254 ----------
1255 enc : Callable or None
1256 Ignored (Golomb encoding uses its own multi-qubit gate).
1258 Returns
1259 -------
1260 Callable
1261 A callable with the same signature as per-qubit encoding
1262 functions but that applies :func:`GolombEncoding`.
1263 """
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)
1269 return _enc