Skip to content

References

Ansaetze#

from qml_essentials.ansaetze import Ansaetze
Source code in qml_essentials/ansaetze.py
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
class Ansaetze:
    def get_available(parameterized_only=False):
        # list of parameterized ansaetze
        ansaetze = [
            Ansaetze.Circuit_1,
            Ansaetze.Circuit_2,
            Ansaetze.Circuit_3,
            Ansaetze.Circuit_4,
            Ansaetze.Circuit_5,
            Ansaetze.Circuit_6,
            Ansaetze.Circuit_7,
            Ansaetze.Circuit_8,
            Ansaetze.Circuit_9,
            Ansaetze.Circuit_10,
            Ansaetze.Circuit_13,
            Ansaetze.Circuit_14,
            Ansaetze.Circuit_15,
            Ansaetze.Circuit_16,
            Ansaetze.Circuit_17,
            Ansaetze.Circuit_18,
            Ansaetze.Circuit_19,
            Ansaetze.Circuit_20,
            Ansaetze.No_Entangling,
            Ansaetze.Strongly_Entangling,
            Ansaetze.Hardware_Efficient,
        ]

        # extend by the non-parameterized ones
        if not parameterized_only:
            ansaetze += [
                Ansaetze.No_Ansatz,
                Ansaetze.GHZ,
            ]

        return ansaetze

    class No_Ansatz(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return ()

    class GHZ(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.H),
                Block(gate=Gates.CX, topology=Topology.stairs, reverse=True),
            )

        @classmethod
        def build(cls, w: np.ndarray, n_qubits: int, **kwargs):
            Gates.H(wires=0, **kwargs)
            for q in range(n_qubits - 1):
                Gates.CX(wires=[q, q + 1], **kwargs)

        @classmethod
        def n_pulse_params_per_layer(cls, n_qubits: int) -> int:
            n_params = PulseInformation.num_params("H")  # only 1 H
            n_params += (n_qubits - 1) * PulseInformation.num_params(Gates.CX)
            return n_params

    class Circuit_1(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
            )

    class Circuit_2(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                ),
            )

    class Circuit_3(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(gate=Gates.CRZ, topology=Topology.stairs),
            )

    class Circuit_4(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(gate=Gates.CRX, topology=Topology.stairs),
            )

    class Circuit_5(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(gate=Gates.CRZ, topology=Topology.all_to_all),
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
            )

    class Circuit_6(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(gate=Gates.CRX, topology=Topology.all_to_all),
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
            )

    class Circuit_7(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.bricks,
                ),
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.bricks,
                    offset=1,
                ),
            )

    class Circuit_8(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.bricks,
                ),
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.bricks,
                    offset=1,
                ),
            )

    class Circuit_9(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.H),
                Block(gate="CZ", topology=Topology.stairs),
                Block(gate=Gates.RX),
            )

    class Circuit_10(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(gate="CZ", topology=Topology.stairs, offset=-1, wrap=True),
                Block(gate=Gates.RY),
            )

    class Circuit_13(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.stairs,
                    wrap=True,
                    reverse=True,
                    mirror=False,
                ),
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.stairs,
                    reverse=False,
                    mirror=False,
                    offset=lambda n: n - 1,
                    span=3,
                    wrap=True,
                ),
            )

    class Circuit_14(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.stairs,
                    wrap=True,
                    reverse=True,
                    mirror=False,
                ),
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.stairs,
                    reverse=False,
                    mirror=False,
                    offset=lambda n: n - 1,
                    span=3,
                    wrap=True,
                ),
            )

    class Circuit_15(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    wrap=True,
                    reverse=True,
                    mirror=False,
                ),
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    reverse=False,
                    mirror=False,
                    offset=lambda n: n - 1,
                    span=3,
                    wrap=True,
                ),
            )

    class Circuit_16(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.bricks,
                ),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.bricks,
                    offset=1,
                ),
            )

    class Circuit_17(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.bricks,
                ),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.bricks,
                    offset=1,
                ),
            )

    class Circuit_18(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRZ,
                    topology=Topology.stairs,
                    wrap=True,
                    mirror=False,
                ),
            )

    class Circuit_19(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RX),
                Block(gate=Gates.RZ),
                Block(
                    gate=Gates.CRX,
                    topology=Topology.stairs,
                    wrap=True,
                    mirror=False,
                ),
            )

    class Circuit_20(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    wrap=True,
                    reverse=True,
                    mirror=False,
                ),
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    reverse=False,
                    offset=lambda n: n - 2,
                    span=1,
                    wrap=True,
                ),
            )

    class No_Entangling(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (Block(gate=Gates.Rot),)

    class Hardware_Efficient(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.RY),
                Block(gate=Gates.RZ),
                Block(gate=Gates.RY),
                Block(
                    gate=Gates.CX,
                    topology=Topology.bricks,
                    mirror=False,
                ),
                Block(
                    gate=Gates.CX,
                    topology=Topology.bricks,
                    offset=-1,
                    modulo=True,
                    wrap=True,
                    mirror=False,
                ),
            )

    class Strongly_Entangling(DeclarativeCircuit):
        @classmethod
        def structure(cls):
            return (
                Block(gate=Gates.Rot),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    wrap=True,
                    reverse=False,
                    mirror=False,
                ),
                Block(gate=Gates.Rot),
                Block(
                    gate=Gates.CX,
                    topology=Topology.stairs,
                    reverse=False,
                    span=lambda n: n // 2,
                    wrap=True,
                    mirror=False,
                ),
            )

Circuit#

from qml_essentials.ansaetze import Circuit

Bases: ABC

Abstract base class for quantum circuit ansätze.

Source code in qml_essentials/ansaetze.py
class Circuit(ABC):
    """Abstract base class for quantum circuit ansätze."""

    def __init__(self) -> None:
        """Initialize the circuit."""
        pass

    @abstractmethod
    def n_params_per_layer(self, n_qubits: int) -> int:
        """
        Get the number of parameters per circuit layer.

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

        Returns:
            int: Number of parameters required per layer.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("n_params_per_layer method is not implemented")

    def n_pulse_params_per_layer(self, n_qubits: int) -> int:
        """
        Get the number of pulse parameters per circuit layer.

        Subclasses that do not use pulse-level simulation do not need to
        override this method.

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

        Returns:
            int: Number of pulse parameters required per layer.

        Raises:
            NotImplementedError: If called but not overridden by subclass.
        """
        raise NotImplementedError("n_pulse_params_per_layer method is not implemented")

    @abstractmethod
    def get_control_indices(self, n_qubits: int) -> Optional[List[int]]:
        """
        Get indices for controlled rotation gates in one layer.

        Returns slice indices [start:stop:step] for extracting controlled
        gate parameters from a full parameter array for one layer.

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

        Returns:
            Optional[List[int]]: List of three integers [start, stop, step]
                for slicing, or None if the circuit contains no controlled
                rotation gates.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("get_control_indices method is not implemented")

    def get_control_angles(self, w: np.ndarray, n_qubits: int) -> Optional[np.ndarray]:
        """
        Extract angles for controlled rotation gates from parameter array.

        Args:
            w (np.ndarray): Parameter array for one layer.
            n_qubits (int): Number of qubits in the circuit.

        Returns:
            Optional[np.ndarray]: Array of controlled gate parameters,
                or empty array if circuit contains no controlled gates.
        """
        indices = self.get_control_indices(n_qubits)
        if indices is None:
            return np.array([])

        if len(indices) == 3 and None in indices:
            return w[indices[0] : indices[1] : indices[2]]
        else:
            return w.take(np.array(indices))

    def _build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any:
        """
        Build one layer of the circuit using unitary or pulse-level parameters.

        Internal method that handles pulse parameter validation and context
        management before delegating to the build() method.

        Args:
            w (np.ndarray): Parameter array for the current layer.
            n_qubits (int): Number of qubits in the circuit.
            **kwargs: Additional keyword arguments:
                - gate_mode (str): "unitary" (default) or "pulse" for
                  pulse-level simulation.
                - pulse_params (np.ndarray): Pulse parameters if gate_mode="pulse".
                - noise_params (Dict): Noise parameters dictionary.

        Returns:
            Any: Result from the build() method.

        Raises:
            ValueError: If pulse_params length doesn't match expected count.
        """
        gate_mode = kwargs.get("gate_mode", "unitary")

        if gate_mode == "pulse" and "pulse_params" in kwargs:
            pulse_params_per_layer = self.n_pulse_params_per_layer(n_qubits)

            if len(kwargs["pulse_params"]) != pulse_params_per_layer:
                raise ValueError(
                    f"Pulse params length {len(kwargs['pulse_params'])} "
                    f"does not match expected {pulse_params_per_layer} "
                    f"for {n_qubits} qubits"
                )

            with Gates.pulse_manager_context(kwargs["pulse_params"]):
                return self.build(w, n_qubits, **kwargs)
        else:
            return self.build(w, n_qubits, **kwargs)

    @abstractmethod
    def build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any:
        """
        Build one layer of the quantum circuit.

        Args:
            w (np.ndarray): Parameter array for the current layer.
            n_qubits (int): Number of qubits in the circuit.
            **kwargs: Additional keyword arguments passed from _build.

        Returns:
            Any: Circuit construction result.

        Raises:
            NotImplementedError: Must be implemented by subclasses.
        """
        raise NotImplementedError("build method is not implemented")

    def __call__(self, *args: Any, **kwds: Any) -> Any:
        """Call the _build method with provided arguments."""
        self._build(*args, **kwds)

__call__(*args, **kwds) #

Call the _build method with provided arguments.

Source code in qml_essentials/ansaetze.py
def __call__(self, *args: Any, **kwds: Any) -> Any:
    """Call the _build method with provided arguments."""
    self._build(*args, **kwds)

__init__() #

Initialize the circuit.

Source code in qml_essentials/ansaetze.py
def __init__(self) -> None:
    """Initialize the circuit."""
    pass

build(w, n_qubits, **kwargs) abstractmethod #

Build one layer of the quantum circuit.

Parameters:

Name Type Description Default
w ndarray

Parameter array for the current layer.

required
n_qubits int

Number of qubits in the circuit.

required
**kwargs Any

Additional keyword arguments passed from _build.

{}

Returns:

Name Type Description
Any Any

Circuit construction result.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in qml_essentials/ansaetze.py
@abstractmethod
def build(self, w: np.ndarray, n_qubits: int, **kwargs: Any) -> Any:
    """
    Build one layer of the quantum circuit.

    Args:
        w (np.ndarray): Parameter array for the current layer.
        n_qubits (int): Number of qubits in the circuit.
        **kwargs: Additional keyword arguments passed from _build.

    Returns:
        Any: Circuit construction result.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("build method is not implemented")

get_control_angles(w, n_qubits) #

Extract angles for controlled rotation gates from parameter array.

Parameters:

Name Type Description Default
w ndarray

Parameter array for one layer.

required
n_qubits int

Number of qubits in the circuit.

required

Returns:

Type Description
Optional[ndarray]

Optional[np.ndarray]: Array of controlled gate parameters, or empty array if circuit contains no controlled gates.

Source code in qml_essentials/ansaetze.py
def get_control_angles(self, w: np.ndarray, n_qubits: int) -> Optional[np.ndarray]:
    """
    Extract angles for controlled rotation gates from parameter array.

    Args:
        w (np.ndarray): Parameter array for one layer.
        n_qubits (int): Number of qubits in the circuit.

    Returns:
        Optional[np.ndarray]: Array of controlled gate parameters,
            or empty array if circuit contains no controlled gates.
    """
    indices = self.get_control_indices(n_qubits)
    if indices is None:
        return np.array([])

    if len(indices) == 3 and None in indices:
        return w[indices[0] : indices[1] : indices[2]]
    else:
        return w.take(np.array(indices))

get_control_indices(n_qubits) abstractmethod #

Get indices for controlled rotation gates in one layer.

Returns slice indices [start:stop:step] for extracting controlled gate parameters from a full parameter array for one layer.

Parameters:

Name Type Description Default
n_qubits int

Number of qubits in the circuit.

required

Returns:

Type Description
Optional[List[int]]

Optional[List[int]]: List of three integers [start, stop, step] for slicing, or None if the circuit contains no controlled rotation gates.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in qml_essentials/ansaetze.py
@abstractmethod
def get_control_indices(self, n_qubits: int) -> Optional[List[int]]:
    """
    Get indices for controlled rotation gates in one layer.

    Returns slice indices [start:stop:step] for extracting controlled
    gate parameters from a full parameter array for one layer.

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

    Returns:
        Optional[List[int]]: List of three integers [start, stop, step]
            for slicing, or None if the circuit contains no controlled
            rotation gates.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("get_control_indices method is not implemented")

n_params_per_layer(n_qubits) abstractmethod #

Get the number of parameters per circuit layer.

Parameters:

Name Type Description Default
n_qubits int

Number of qubits in the circuit.

required

Returns:

Name Type Description
int int

Number of parameters required per layer.

Raises:

Type Description
NotImplementedError

Must be implemented by subclasses.

Source code in qml_essentials/ansaetze.py
@abstractmethod
def n_params_per_layer(self, n_qubits: int) -> int:
    """
    Get the number of parameters per circuit layer.

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

    Returns:
        int: Number of parameters required per layer.

    Raises:
        NotImplementedError: Must be implemented by subclasses.
    """
    raise NotImplementedError("n_params_per_layer method is not implemented")

n_pulse_params_per_layer(n_qubits) #

Get the number of pulse parameters per circuit layer.

Subclasses that do not use pulse-level simulation do not need to override this method.

Parameters:

Name Type Description Default
n_qubits int

Number of qubits in the circuit.

required

Returns:

Name Type Description
int int

Number of pulse parameters required per layer.

Raises:

Type Description
NotImplementedError

If called but not overridden by subclass.

Source code in qml_essentials/ansaetze.py
def n_pulse_params_per_layer(self, n_qubits: int) -> int:
    """
    Get the number of pulse parameters per circuit layer.

    Subclasses that do not use pulse-level simulation do not need to
    override this method.

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

    Returns:
        int: Number of pulse parameters required per layer.

    Raises:
        NotImplementedError: If called but not overridden by subclass.
    """
    raise NotImplementedError("n_pulse_params_per_layer method is not implemented")

Declarative Circuit#

from qml_essentials.ansaetze import DeclarativeCircuit

Bases: Circuit

A circuit defined entirely by a sequence of Block descriptors.

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

All of n_params_per_layer, n_pulse_params_per_layer, get_control_indices, and build are derived automatically.

Source code in qml_essentials/ansaetze.py
class DeclarativeCircuit(Circuit):
    """
    A circuit defined entirely by a sequence of Block descriptors.

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

    All of `n_params_per_layer`, `n_pulse_params_per_layer`,
    `get_control_indices`, and `build` are derived automatically.
    """

    @classmethod
    def structure(cls) -> Tuple[Any, ...]:
        """Override in subclass to return the structure tuple."""
        raise NotImplementedError

    @classmethod
    def n_params_per_layer(cls, n_qubits: int) -> int:
        return sum(block.n_params(n_qubits) for block in cls.structure())

    @classmethod
    def n_pulse_params_per_layer(cls, n_qubits: int) -> int:
        return sum(block.n_pulse_params(n_qubits) for block in cls.structure())

    @classmethod
    def get_control_indices(cls, n_qubits: int) -> Optional[List]:
        """
        Computes parameter indices for controlled rotation Gates.
        Scans the structure for Block with
        [start, stop, step] into the flat parameter vector, or None.
        """
        structure = cls.structure()
        total_params = sum(block.n_params(n_qubits) for block in structure)

        # Collect which parameter indices correspond to controlled rotations
        controlled_indices = []
        offset = 0
        for block in structure:
            n = block.n_params(n_qubits)
            if block.is_controlled_rotation:
                controlled_indices.extend(range(offset, offset + n))
            offset += n

        # FIXME: this last part should be reworked

        if not controlled_indices:
            return None

        # Check if indices form a contiguous tail (the common case)
        # This preserves backwards compatibility with the [start, None, None] format
        if controlled_indices == list(
            range(total_params - len(controlled_indices), total_params)
        ):
            return [-len(controlled_indices), None, None]

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

    @classmethod
    def build(cls, w: np.ndarray, n_qubits: int, **kwargs: Any) -> None:
        structure = cls.structure()
        w_idx = 0
        for block in structure:
            w_idx = block.apply(n_qubits, w, w_idx, **kwargs)
            Gates.Barrier(wires=list(range(n_qubits)), **kwargs)

get_control_indices(n_qubits) classmethod #

Computes parameter indices for controlled rotation Gates. Scans the structure for Block with [start, stop, step] into the flat parameter vector, or None.

Source code in qml_essentials/ansaetze.py
@classmethod
def get_control_indices(cls, n_qubits: int) -> Optional[List]:
    """
    Computes parameter indices for controlled rotation Gates.
    Scans the structure for Block with
    [start, stop, step] into the flat parameter vector, or None.
    """
    structure = cls.structure()
    total_params = sum(block.n_params(n_qubits) for block in structure)

    # Collect which parameter indices correspond to controlled rotations
    controlled_indices = []
    offset = 0
    for block in structure:
        n = block.n_params(n_qubits)
        if block.is_controlled_rotation:
            controlled_indices.extend(range(offset, offset + n))
        offset += n

    # FIXME: this last part should be reworked

    if not controlled_indices:
        return None

    # Check if indices form a contiguous tail (the common case)
    # This preserves backwards compatibility with the [start, None, None] format
    if controlled_indices == list(
        range(total_params - len(controlled_indices), total_params)
    ):
        return [-len(controlled_indices), None, None]

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

structure() classmethod #

Override in subclass to return the structure tuple.

Source code in qml_essentials/ansaetze.py
@classmethod
def structure(cls) -> Tuple[Any, ...]:
    """Override in subclass to return the structure tuple."""
    raise NotImplementedError

Block#

from qml_essentials.ansaetze import Block
Source code in qml_essentials/ansaetze.py
class Block:
    def __init__(
        self,
        gate: str,
        topology: Any = None,
        **kwargs,
    ):
        """
        Initialize a Block object; the atoms of Ansatzes.

        Args:
            gate (str): Name of the Gate class to use.
            topology (Any, optional): Topology of the gate for entangling gates.
                Defaults to None.
            kwargs (Any): Additional keyword arguments passed to the topology function.
        """
        if isinstance(gate, str):
            self.gate = getattr(Gates, gate)
        else:
            self.gate = gate

        if self.is_entangling:
            assert topology is not None, (
                "Topology must be specified for entangling gates"
            )

        self.topology = topology
        self.kwargs = kwargs

    def __repr__(self):
        if self.topology is None:
            return f"{self.__class__.__name__}({self.gate.__name__})"
        else:
            return (
                f"{self.__class__.__name__}"
                f"({self.topology.__name__}[{self.gate.__name__}])"
            )

    @property
    def is_entangling(self):
        return Gates.is_entangling(self.gate)

    @property
    def is_rotational(self):
        return Gates.is_rotational(self.gate)

    @property
    def is_controlled_rotation(self):
        return self.is_entangling and self.is_rotational

    def enough_qubits(self, n_qubits):
        if self.is_entangling:
            # NOTE This must be adjusted if default values
            # in Topology change
            span = self.kwargs.get("span", 1)
            if callable(span):
                span = span(n_qubits)

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

        return n_qubits >= 1

    def n_params(self, n_qubits: int) -> int:
        assert n_qubits > 0, "Number of qubits must be positive"

        if self.is_rotational:
            if self.is_entangling:
                if not self.enough_qubits(n_qubits):
                    warnings.warn(
                        f"Skipping {self.topology.__name__} with n_qubits={n_qubits} "
                        f"as there are not enough qubits"
                        f"for this topology."
                    )
                    return 0
                else:
                    return len(self.topology(n_qubits=n_qubits, **self.kwargs))
            else:
                return n_qubits if self.gate.__name__ != "Rot" else 3 * n_qubits

        return 0

    def n_pulse_params(self, n_qubits: int) -> int:
        assert n_qubits > 0, "Number of qubits must be positive"

        n_pulse_params = PulseInformation.num_params(self.gate)
        if self.is_entangling:
            if not self.enough_qubits(n_qubits):
                warnings.warn(
                    f"Skipping {self.topology.__name__} with n_qubits={n_qubits} "
                    f"as there are not enough qubits"
                    f"for this topology."
                )
                return 0
            else:
                return n_pulse_params * len(
                    self.topology(n_qubits=n_qubits, **self.kwargs)
                )
        return n_pulse_params * n_qubits

    def apply(
        self, n_qubits: int, w: np.ndarray = None, w_idx: int = None, **kwargs
    ) -> int:
        """
        Applies the block to the given circuit.

        Args:
            n_qubits (int): Number of qubits, the block is applied to.
            w (np.ndarray, optional): Weights to use for rotational gates.
                Defaults to None.
            w_idx (int, optional): Index of weights to use for rotational gates.
                Defaults to None.
            **kwargs (Any): Keyword arguments passed to the gate.

        Returns:
            int: The new index of weights after applying the block.
        """
        assert n_qubits > 0, "Number of qubits must be positive"

        iterator = (
            self.topology(n_qubits=n_qubits, **self.kwargs)
            if self.is_entangling
            else range(n_qubits)
        )

        for wires in iterator:
            if self.is_entangling and not self.enough_qubits(n_qubits):
                warnings.warn(
                    f"Skipping {self.topology.__name__} with n_qubits={n_qubits} "
                    f"as there are not enough qubits"
                    f"for this topology."
                )
                continue

            if self.is_rotational:
                assert w is not None, "w must be provided for rotational gates"
                assert w_idx is not None, "w_idx must be provided for rotational gates"

                if self.gate.__name__ == "Rot":
                    self.gate(
                        w[w_idx], w[w_idx + 1], w[w_idx + 2], wires=wires, **kwargs
                    )
                    w_idx += 3
                else:
                    self.gate(w[w_idx], wires=wires, **kwargs)
                    w_idx += 1
            else:
                self.gate(wires=wires, **kwargs)
        return w_idx

__init__(gate, topology=None, **kwargs) #

Initialize a Block object; the atoms of Ansatzes.

Parameters:

Name Type Description Default
gate str

Name of the Gate class to use.

required
topology Any

Topology of the gate for entangling gates. Defaults to None.

None
kwargs Any

Additional keyword arguments passed to the topology function.

{}
Source code in qml_essentials/ansaetze.py
def __init__(
    self,
    gate: str,
    topology: Any = None,
    **kwargs,
):
    """
    Initialize a Block object; the atoms of Ansatzes.

    Args:
        gate (str): Name of the Gate class to use.
        topology (Any, optional): Topology of the gate for entangling gates.
            Defaults to None.
        kwargs (Any): Additional keyword arguments passed to the topology function.
    """
    if isinstance(gate, str):
        self.gate = getattr(Gates, gate)
    else:
        self.gate = gate

    if self.is_entangling:
        assert topology is not None, (
            "Topology must be specified for entangling gates"
        )

    self.topology = topology
    self.kwargs = kwargs

apply(n_qubits, w=None, w_idx=None, **kwargs) #

Applies the block to the given circuit.

Parameters:

Name Type Description Default
n_qubits int

Number of qubits, the block is applied to.

required
w ndarray

Weights to use for rotational gates. Defaults to None.

None
w_idx int

Index of weights to use for rotational gates. Defaults to None.

None
**kwargs Any

Keyword arguments passed to the gate.

{}

Returns:

Name Type Description
int int

The new index of weights after applying the block.

Source code in qml_essentials/ansaetze.py
def apply(
    self, n_qubits: int, w: np.ndarray = None, w_idx: int = None, **kwargs
) -> int:
    """
    Applies the block to the given circuit.

    Args:
        n_qubits (int): Number of qubits, the block is applied to.
        w (np.ndarray, optional): Weights to use for rotational gates.
            Defaults to None.
        w_idx (int, optional): Index of weights to use for rotational gates.
            Defaults to None.
        **kwargs (Any): Keyword arguments passed to the gate.

    Returns:
        int: The new index of weights after applying the block.
    """
    assert n_qubits > 0, "Number of qubits must be positive"

    iterator = (
        self.topology(n_qubits=n_qubits, **self.kwargs)
        if self.is_entangling
        else range(n_qubits)
    )

    for wires in iterator:
        if self.is_entangling and not self.enough_qubits(n_qubits):
            warnings.warn(
                f"Skipping {self.topology.__name__} with n_qubits={n_qubits} "
                f"as there are not enough qubits"
                f"for this topology."
            )
            continue

        if self.is_rotational:
            assert w is not None, "w must be provided for rotational gates"
            assert w_idx is not None, "w_idx must be provided for rotational gates"

            if self.gate.__name__ == "Rot":
                self.gate(
                    w[w_idx], w[w_idx + 1], w[w_idx + 2], wires=wires, **kwargs
                )
                w_idx += 3
            else:
                self.gate(w[w_idx], wires=wires, **kwargs)
                w_idx += 1
        else:
            self.gate(wires=wires, **kwargs)
    return w_idx

Encoding#

from qml_essentials.ansaetze import Encoding
Source code in qml_essentials/ansaetze.py
class Encoding:
    def __init__(
        self, strategy: str, gates: Union[str, Callable, List[Union[str, Callable]]]
    ):
        """
        Initializes an Encoding object.

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

        Parameters
        ----------
        strategy : str
            The encoding strategy to use. Available options:
            ['hamming', 'binary', 'ternary']
        gates : Union[str, Callable, List[Union[str, Callable]]]
            The gates to use for encoding. Can be a string, a callable or a list
            of strings or callables.

        Returns
        -------
        None

        Raises
        -------
        ValueError
            If the encoding strategy is not implemented.
        ValueError
            If there is an error parsing the Gates.
        """
        if strategy not in ["hamming", "binary", "ternary", "golomb"]:
            raise ValueError(
                f"Encoding strategy {strategy} not implemented. "
                "Available options: ['hamming', 'binary', 'ternary', 'golomb']"
            )
        self._strategy = strategy
        strategy_fn = getattr(self, strategy)

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

        if self._strategy == "golomb":
            self._gates = []
            self.callable = [strategy_fn(None)]
        else:
            try:
                self._gates = Gates.parse_gates(gates, Gates)
            except ValueError as e:
                raise ValueError(f"Error parsing encodings: {e}")

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

    def __len__(self):
        return len(self.callable)

    def __getitem__(self, idx):
        return self.callable[idx]

    def get_n_freqs(self, omegas):
        """
        Returns the number of frequencies required for the encoding strategy.
        This includes positive and negative side.

        Parameters
        ----------
        omegas : int
            The number of frequencies to encode.

        Returns
        -------
        int
            The number of frequencies required for the encoding strategy.
        """
        if self._strategy == "hamming":
            return int(2 * omegas + 1)
        elif self._strategy == "binary":
            return int(2 ** (omegas + 1) - 1)
        elif self._strategy == "ternary":
            return int(3 ** (omegas))
        elif self._strategy == "golomb":
            from qml_essentials.unitary import golomb_ruler

            n_qubits = getattr(self, "_n_qubits", None)
            if n_qubits is None:
                raise ValueError("Golomb encoding requires n_qubits to be set")

            d = 2**n_qubits
            marks = golomb_ruler(d)
            max_mark = max(marks)
            return int(2 * omegas * max_mark + 1)
        else:
            raise NotImplementedError

    def get_spectrum(self, omegas):
        """
        Spectrum for one of the following encoding strategies:

        Hamming: {-n_q -(n_q-1), ..., n_q}
        Binary: {-2^{n_q}+1, ..., 2^{n_q}-1}
        Ternary: {-floor(3^{n_q}/2), ..., floor(3^(n_q)/2)}
        Golomb: all pairwise differences of Golomb ruler marks,
                scaled by the number of encoding applications

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

        Parameters
        ----------
        omegas : int
            The number of frequencies to encode.

        Returns
        -------
        np.ndarray
            The spectrum of the encoding strategy.
        """
        if self._strategy == "hamming":
            return np.arange(-omegas, omegas + 1)
        elif self._strategy == "binary":
            return np.arange(-(2**omegas) + 1, 2**omegas)
        elif self._strategy == "ternary":
            limit = int(np.floor(3**omegas / 2))
            return np.arange(-limit, limit + 1)
        elif self._strategy == "golomb":
            from qml_essentials.unitary import golomb_ruler

            n_qubits = getattr(self, "_n_qubits", None)
            if n_qubits is None:
                raise ValueError("Golomb encoding requires n_qubits to be set")
            d = 2**n_qubits
            marks = golomb_ruler(d)
            max_mark = max(marks)
            limit = omegas * max_mark
            return np.arange(-limit, limit + 1)
        else:
            raise NotImplementedError

    def hamming(self, enc):
        """
        Hamming encoding strategy.

        Returns an encoding function that uses the Hamming encoding strategy
        which uses 2 * omegas + 1 frequencies for the encoding.
        See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

        Parameters
        ----------
        enc : Callable
            The encoding function to be wrapped.

        Returns
        -------
        Callable
            The wrapped encoding function.
        """
        return enc

    def binary(self, enc):
        """
        Binary encoding strategy.

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

        Binary encoding uses 2^(omegas + 1) - 1 frequencies for the encoding.
        See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

        Parameters
        ----------
        enc : Callable
            The encoding function to be wrapped.

        Returns
        -------
        Callable
            The wrapped encoding function.
        """

        def _enc(inputs, wires, **kwargs):
            return enc(inputs * (2**wires), wires, **kwargs)

        return _enc

    def ternary(self, enc):
        """
        Ternary encoding strategy.

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

        Ternary encoding uses 3^(omegas + 1) - 1 frequencies for the encoding.
        See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

        Parameters
        ----------
        enc : Callable
            The encoding function to be wrapped.

        Returns
        -------
        Callable
            The wrapped encoding function.
        """

        def _enc(inputs, wires, **kwargs):
            return enc(inputs * (3**wires), wires, **kwargs)

        return _enc

    @property
    def is_golomb(self):
        """Whether this encoding uses the Golomb (multi-qubit diagonal) strategy."""
        return self._strategy == "golomb"

    def golomb(self, enc):
        """Golomb encoding strategy.

        Returns a callable that applies a multi-qubit diagonal unitary
        ``S(x) = exp(-i H x)`` where ``H = diag(golomb_marks)`` to all
        qubits simultaneously.  This produces the largest possible
        ``|Ω| = d(d-1)+1`` for any *d*-dimensional Hamiltonian, with
        ``|R(k)| = 1`` for all nonzero frequencies *k*.

        Unlike the other strategies, Golomb encoding does *not* wrap a
        per-qubit gate.  Instead, the model's ``_iec`` method detects
        ``is_golomb`` and applies a single ``GolombEncoding`` gate on
        all qubits.

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

        Parameters
        ----------
        enc : Callable or None
            Ignored (Golomb encoding uses its own multi-qubit gate).

        Returns
        -------
        Callable
            A callable with the same signature as per-qubit encoding
            functions but that applies ``Gates.GolombEncoding``.
        """

        def _enc(inputs, wires, **kwargs):
            # `wires` here is a list of all qubit indices, set by _iec
            Gates.GolombEncoding(w=inputs, wires=wires, **kwargs)

        return _enc

is_golomb property #

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

__init__(strategy, gates) #

Initializes an Encoding object.

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

Parameters#

strategy : str The encoding strategy to use. Available options: ['hamming', 'binary', 'ternary'] gates : Union[str, Callable, List[Union[str, Callable]]] The gates to use for encoding. Can be a string, a callable or a list of strings or callables.

Returns#

None

Raises#

ValueError If the encoding strategy is not implemented. ValueError If there is an error parsing the Gates.

Source code in qml_essentials/ansaetze.py
def __init__(
    self, strategy: str, gates: Union[str, Callable, List[Union[str, Callable]]]
):
    """
    Initializes an Encoding object.

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

    Parameters
    ----------
    strategy : str
        The encoding strategy to use. Available options:
        ['hamming', 'binary', 'ternary']
    gates : Union[str, Callable, List[Union[str, Callable]]]
        The gates to use for encoding. Can be a string, a callable or a list
        of strings or callables.

    Returns
    -------
    None

    Raises
    -------
    ValueError
        If the encoding strategy is not implemented.
    ValueError
        If there is an error parsing the Gates.
    """
    if strategy not in ["hamming", "binary", "ternary", "golomb"]:
        raise ValueError(
            f"Encoding strategy {strategy} not implemented. "
            "Available options: ['hamming', 'binary', 'ternary', 'golomb']"
        )
    self._strategy = strategy
    strategy_fn = getattr(self, strategy)

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

    if self._strategy == "golomb":
        self._gates = []
        self.callable = [strategy_fn(None)]
    else:
        try:
            self._gates = Gates.parse_gates(gates, Gates)
        except ValueError as e:
            raise ValueError(f"Error parsing encodings: {e}")

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

binary(enc) #

Binary encoding strategy.

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

Binary encoding uses 2^(omegas + 1) - 1 frequencies for the encoding. See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

Parameters#

enc : Callable The encoding function to be wrapped.

Returns#

Callable The wrapped encoding function.

Source code in qml_essentials/ansaetze.py
def binary(self, enc):
    """
    Binary encoding strategy.

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

    Binary encoding uses 2^(omegas + 1) - 1 frequencies for the encoding.
    See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

    Parameters
    ----------
    enc : Callable
        The encoding function to be wrapped.

    Returns
    -------
    Callable
        The wrapped encoding function.
    """

    def _enc(inputs, wires, **kwargs):
        return enc(inputs * (2**wires), wires, **kwargs)

    return _enc

get_n_freqs(omegas) #

Returns the number of frequencies required for the encoding strategy. This includes positive and negative side.

Parameters#

omegas : int The number of frequencies to encode.

Returns#

int The number of frequencies required for the encoding strategy.

Source code in qml_essentials/ansaetze.py
def get_n_freqs(self, omegas):
    """
    Returns the number of frequencies required for the encoding strategy.
    This includes positive and negative side.

    Parameters
    ----------
    omegas : int
        The number of frequencies to encode.

    Returns
    -------
    int
        The number of frequencies required for the encoding strategy.
    """
    if self._strategy == "hamming":
        return int(2 * omegas + 1)
    elif self._strategy == "binary":
        return int(2 ** (omegas + 1) - 1)
    elif self._strategy == "ternary":
        return int(3 ** (omegas))
    elif self._strategy == "golomb":
        from qml_essentials.unitary import golomb_ruler

        n_qubits = getattr(self, "_n_qubits", None)
        if n_qubits is None:
            raise ValueError("Golomb encoding requires n_qubits to be set")

        d = 2**n_qubits
        marks = golomb_ruler(d)
        max_mark = max(marks)
        return int(2 * omegas * max_mark + 1)
    else:
        raise NotImplementedError

get_spectrum(omegas) #

Spectrum for one of the following encoding strategies:

Hamming: {-n_q -(n_q-1), ..., n_q} Binary: {-2^{n_q}+1, ..., 2^{n_q}-1} Ternary: {-floor(3^{n_q}/2), ..., floor(3^(n_q)/2)} Golomb: all pairwise differences of Golomb ruler marks, scaled by the number of encoding applications

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

Parameters#

omegas : int The number of frequencies to encode.

Returns#

np.ndarray The spectrum of the encoding strategy.

Source code in qml_essentials/ansaetze.py
def get_spectrum(self, omegas):
    """
    Spectrum for one of the following encoding strategies:

    Hamming: {-n_q -(n_q-1), ..., n_q}
    Binary: {-2^{n_q}+1, ..., 2^{n_q}-1}
    Ternary: {-floor(3^{n_q}/2), ..., floor(3^(n_q)/2)}
    Golomb: all pairwise differences of Golomb ruler marks,
            scaled by the number of encoding applications

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

    Parameters
    ----------
    omegas : int
        The number of frequencies to encode.

    Returns
    -------
    np.ndarray
        The spectrum of the encoding strategy.
    """
    if self._strategy == "hamming":
        return np.arange(-omegas, omegas + 1)
    elif self._strategy == "binary":
        return np.arange(-(2**omegas) + 1, 2**omegas)
    elif self._strategy == "ternary":
        limit = int(np.floor(3**omegas / 2))
        return np.arange(-limit, limit + 1)
    elif self._strategy == "golomb":
        from qml_essentials.unitary import golomb_ruler

        n_qubits = getattr(self, "_n_qubits", None)
        if n_qubits is None:
            raise ValueError("Golomb encoding requires n_qubits to be set")
        d = 2**n_qubits
        marks = golomb_ruler(d)
        max_mark = max(marks)
        limit = omegas * max_mark
        return np.arange(-limit, limit + 1)
    else:
        raise NotImplementedError

golomb(enc) #

Golomb encoding strategy.

Returns a callable that applies a multi-qubit diagonal unitary S(x) = exp(-i H x) where H = diag(golomb_marks) to all qubits simultaneously. This produces the largest possible |Ω| = d(d-1)+1 for any d-dimensional Hamiltonian, with |R(k)| = 1 for all nonzero frequencies k.

Unlike the other strategies, Golomb encoding does not wrap a per-qubit gate. Instead, the model's _iec method detects is_golomb and applies a single GolombEncoding gate on all qubits.

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

Parameters#

enc : Callable or None Ignored (Golomb encoding uses its own multi-qubit gate).

Returns#

Callable A callable with the same signature as per-qubit encoding functions but that applies Gates.GolombEncoding.

Source code in qml_essentials/ansaetze.py
def golomb(self, enc):
    """Golomb encoding strategy.

    Returns a callable that applies a multi-qubit diagonal unitary
    ``S(x) = exp(-i H x)`` where ``H = diag(golomb_marks)`` to all
    qubits simultaneously.  This produces the largest possible
    ``|Ω| = d(d-1)+1`` for any *d*-dimensional Hamiltonian, with
    ``|R(k)| = 1`` for all nonzero frequencies *k*.

    Unlike the other strategies, Golomb encoding does *not* wrap a
    per-qubit gate.  Instead, the model's ``_iec`` method detects
    ``is_golomb`` and applies a single ``GolombEncoding`` gate on
    all qubits.

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

    Parameters
    ----------
    enc : Callable or None
        Ignored (Golomb encoding uses its own multi-qubit gate).

    Returns
    -------
    Callable
        A callable with the same signature as per-qubit encoding
        functions but that applies ``Gates.GolombEncoding``.
    """

    def _enc(inputs, wires, **kwargs):
        # `wires` here is a list of all qubit indices, set by _iec
        Gates.GolombEncoding(w=inputs, wires=wires, **kwargs)

    return _enc

hamming(enc) #

Hamming encoding strategy.

Returns an encoding function that uses the Hamming encoding strategy which uses 2 * omegas + 1 frequencies for the encoding. See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

Parameters#

enc : Callable The encoding function to be wrapped.

Returns#

Callable The wrapped encoding function.

Source code in qml_essentials/ansaetze.py
def hamming(self, enc):
    """
    Hamming encoding strategy.

    Returns an encoding function that uses the Hamming encoding strategy
    which uses 2 * omegas + 1 frequencies for the encoding.
    See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

    Parameters
    ----------
    enc : Callable
        The encoding function to be wrapped.

    Returns
    -------
    Callable
        The wrapped encoding function.
    """
    return enc

ternary(enc) #

Ternary encoding strategy.

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

Ternary encoding uses 3^(omegas + 1) - 1 frequencies for the encoding. See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

Parameters#

enc : Callable The encoding function to be wrapped.

Returns#

Callable The wrapped encoding function.

Source code in qml_essentials/ansaetze.py
def ternary(self, enc):
    """
    Ternary encoding strategy.

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

    Ternary encoding uses 3^(omegas + 1) - 1 frequencies for the encoding.
    See https://doi.org/10.22331/q-2023-12-20-1210 for more details.

    Parameters
    ----------
    enc : Callable
        The encoding function to be wrapped.

    Returns
    -------
    Callable
        The wrapped encoding function.
    """

    def _enc(inputs, wires, **kwargs):
        return enc(inputs * (3**wires), wires, **kwargs)

    return _enc

Gates#

As the structure of the different classes used to realize pulse and unitary gates can be a bit confusing, the following diagram might help:

Gate Structure Gate Structure

from qml_essentials.gates import Gates

Dynamic accessor for quantum Gates.

Routes calls like Gates.RX(...) to either UnitaryGates or PulseGates depending on the gate_mode keyword (defaults to 'unitary').

During circuit building, the pulse manager can be activated via pulse_manager_context, which slices the global model pulse parameters and passes them to each gate. Model pulse parameters act as element-wise scalers on the gate's optimized pulse parameters.

Parameters#

gate_mode : str, optional Determines the backend. 'unitary' for UnitaryGates, 'pulse' for PulseGates. Defaults to 'unitary'.

Examples#

Gates.RX(w, wires) Gates.RX(w, wires, gate_mode="unitary") Gates.RX(w, wires, gate_mode="pulse") Gates.RX(w, wires, pulse_params, gate_mode="pulse")

Source code in qml_essentials/gates.py
class Gates(metaclass=GatesMeta):
    """
    Dynamic accessor for quantum Gates.

    Routes calls like `Gates.RX(...)` to either `UnitaryGates` or `PulseGates`
    depending on the `gate_mode` keyword (defaults to 'unitary').

    During circuit building, the pulse manager can be activated via
    `pulse_manager_context`, which slices the global model pulse parameters
    and passes them to each gate. Model pulse parameters act as element-wise
    scalers on the gate's optimized pulse parameters.

    Parameters
    ----------
    gate_mode : str, optional
        Determines the backend. 'unitary' for UnitaryGates, 'pulse' for PulseGates.
        Defaults to 'unitary'.

    Examples
    --------
    >>> Gates.RX(w, wires)
    >>> Gates.RX(w, wires, gate_mode="unitary")
    >>> Gates.RX(w, wires, gate_mode="pulse")
    >>> Gates.RX(w, wires, pulse_params, gate_mode="pulse")
    """

    def __getattr__(self, gate_name):
        def handler(**kwargs):
            return self._inner_getattr(gate_name, **kwargs)

        return handler

    @classmethod
    def _inner_getattr(cls, gate_name, *args, **kwargs):
        if gate_name == "Barrier":
            return Barrier(*args, **kwargs)

        gate_mode = kwargs.pop("gate_mode", "unitary")

        # Backend selection and kwargs filtering
        allowed_args = [
            "w",
            "wires",
            "phi",
            "theta",
            "omega",
            "noise_params",
            "random_key",
        ]
        if gate_mode == "unitary":
            gate_backend = UnitaryGates
        elif gate_mode == "pulse":
            gate_backend = PulseGates
            allowed_args += ["pulse_params"]
        else:
            raise ValueError(
                f"Unknown gate mode: {gate_mode}. Use 'unitary' or 'pulse'."
            )

        if len(kwargs.keys() - allowed_args) > 0:
            # TODO: pulse params are always provided?
            log.debug(
                f"Unsupported keyword arguments: {list(kwargs.keys() - allowed_args)}"
            )

        kwargs = {k: v for k, v in kwargs.items() if k in allowed_args}
        pulse_params = kwargs.get("pulse_params")
        pulse_mgr = getattr(cls, "_pulse_mgr", None)

        # TODO: rework this part to convert to valid PulseParams earlier
        # Type check on pulse parameters
        if pulse_params is not None:
            # flatten pulse parameters
            if isinstance(pulse_params, (list, tuple)):
                flat_params = pulse_params

            elif isinstance(pulse_params, jax.core.Tracer):
                flat_params = jnp.ravel(pulse_params)

            elif isinstance(pulse_params, (jnp.ndarray, jnp.ndarray)):
                flat_params = pulse_params.flatten().tolist()
            elif isinstance(pulse_params, PulseParams):
                # extract the params in case a full object is given
                kwargs["pulse_params"] = pulse_params.params
                flat_params = pulse_params.params.flatten().tolist()

            else:
                raise TypeError(f"Unsupported pulse_params type: {type(pulse_params)}")

            # checks elements in flat parameters are real numbers or jax Tracer
            if not all(
                isinstance(x, (numbers.Real, jax.core.Tracer)) for x in flat_params
            ):
                raise TypeError(
                    "All elements in pulse_params must be int or float, "
                    f"got {pulse_params}, type {type(pulse_params)}. "
                )

        # Len check on pulse parameters
        if pulse_params is not None and not isinstance(pulse_mgr, PulseParamManager):
            n_params = PulseInformation.gate_by_name(gate_name).size
            if len(flat_params) != n_params:
                raise ValueError(
                    f"Gate '{gate_name}' expects {n_params} pulse parameters, "
                    f"got {len(flat_params)}"
                )

        # Pulse slicing + scaling
        if gate_mode == "pulse" and isinstance(pulse_mgr, PulseParamManager):
            n_params = PulseInformation.gate_by_name(gate_name).size
            scalers = pulse_mgr.get(n_params)
            base = PulseInformation.gate_by_name(gate_name).params
            kwargs["pulse_params"] = base * scalers

        # Call the selected gate backend
        gate = getattr(gate_backend, gate_name, None)
        if gate is None:
            raise AttributeError(
                f"'{gate_backend.__class__.__name__}' object "
                f"has no attribute '{gate_name}'"
            )

        return gate(*args, **kwargs)

    @classmethod
    @contextmanager
    def pulse_manager_context(cls, pulse_params: jnp.ndarray):
        """Temporarily set the global pulse manager for circuit building."""
        cls._pulse_mgr = PulseParamManager(pulse_params)
        try:
            yield
        finally:
            cls._pulse_mgr = None

    @classmethod
    def parse_gates(
        cls,
        gates: Union[str, Callable, List[Union[str, Callable]]],
        set_of_gates=None,
    ):
        set_of_gates = set_of_gates or cls

        if isinstance(gates, str):
            # if str, use the pennylane fct
            parsed_gates = [getattr(set_of_gates, f"{gates}")]
        elif isinstance(gates, list):
            parsed_gates = []
            for enc in gates:
                # if list, check if str or callable
                if isinstance(enc, str):
                    parsed_gates.append(getattr(set_of_gates, f"{enc}"))
                # check if callable
                elif callable(enc):
                    parsed_gates.append(enc)
                else:
                    raise ValueError(
                        f"Operation {enc} is not a valid gate or callable.\
                        Got {type(enc)}"
                    )
        elif callable(gates):
            # default to callable
            parsed_gates = [gates]
        elif gates is None:
            parsed_gates = [lambda *args, **kwargs: None]
        else:
            raise ValueError(
                f"Operation {gates} is not a valid gate or callable or list of both."
            )
        return parsed_gates

    @classmethod
    def is_rotational(cls, gate):
        return gate.__name__ in [
            "RX",
            "RY",
            "RZ",
            "Rot",
            "CRX",
            "CRY",
            "CRZ",
            "GolombEncoding",
            "CPhase",
        ]

    @classmethod
    def is_entangling(cls, gate):
        return gate.__name__ in ["CX", "CY", "CZ", "CRX", "CRY", "CRZ", "CPhase"]

pulse_manager_context(pulse_params) classmethod #

Temporarily set the global pulse manager for circuit building.

Source code in qml_essentials/gates.py
@classmethod
@contextmanager
def pulse_manager_context(cls, pulse_params: jnp.ndarray):
    """Temporarily set the global pulse manager for circuit building."""
    cls._pulse_mgr = PulseParamManager(pulse_params)
    try:
        yield
    finally:
        cls._pulse_mgr = None

Unitary Gates#

from qml_essentials.gates import UnitaryGates

Collection of unitary quantum gates with optional noise simulation.

Source code in qml_essentials/unitary.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
class UnitaryGates:
    """Collection of unitary quantum gates with optional noise simulation."""

    batch_gate_error = True

    @staticmethod
    def NQubitDepolarizingChannel(p: float, wires: List[int]) -> op.QubitChannel:
        """
        Generate Kraus operators for n-qubit depolarizing channel.

        The n-qubit depolarizing channel models uniform depolarizing noise
        acting on n qubits simultaneously, useful for simulating realistic
        multi-qubit noise affecting entangling gates.

        Args:
            p (float): Total probability of depolarizing error (0 ≤ p ≤ 1).
            wires (List[int]): Qubit indices on which the channel acts.
                Must contain at least 2 qubits.

        Returns:
            op.QubitChannel: QubitChannel with Kraus operators
                representing the depolarizing noise channel.

        Raises:
            ValueError: If p is not in [0, 1] or if fewer than 2 qubits provided.
        """

        def n_qubit_depolarizing_kraus(p: float, n: int) -> List[jnp.ndarray]:
            if not (0.0 <= p <= 1.0):
                raise ValueError(f"Probability p must be between 0 and 1, got {p}")
            if n < 2:
                raise ValueError(f"Number of qubits must be >= 2, got {n}")

            Id = jnp.eye(2)
            X = op.PauliX._matrix
            Y = op.PauliY._matrix
            Z = op.PauliZ._matrix
            paulis = [Id, X, Y, Z]

            dim = 2**n
            all_ops = []

            # Generate all n-qubit Pauli tensor products:
            for indices in itertools.product(range(4), repeat=n):
                P = jnp.eye(1)
                for idx in indices:
                    P = jnp.kron(P, paulis[idx])
                all_ops.append(P)

            # Identity operator corresponds to all zeros indices (Id^n)
            K0 = jnp.sqrt(1 - p * (4**n - 1) / (4**n)) * jnp.eye(dim)

            kraus_ops = []
            for i, P in enumerate(all_ops):
                if i == 0:
                    # Skip the identity, already handled as K0
                    continue
                kraus_ops.append(jnp.sqrt(p / (4**n)) * P)

            return [K0] + kraus_ops

        return op.QubitChannel(n_qubit_depolarizing_kraus(p, len(wires)), wires=wires)

    @staticmethod
    def Noise(
        wires: Union[int, List[int]], noise_params: Optional[Dict[str, float]] = None
    ) -> None:
        """
        Apply noise channels to specified qubits.

        Applies various single-qubit and multi-qubit noise channels based on
        the provided noise parameters dictionary.

        Args:
            wires (Union[int, List[int]]): Qubit index or list of qubit indices
                to apply noise to.
            noise_params (Optional[Dict[str, float]]): Dictionary of noise
                parameters. Supported keys:
                - "BitFlip" (float): Bit flip error probability
                - "PhaseFlip" (float): Phase flip error probability
                - "Depolarizing" (float): Single-qubit depolarizing probability
                - "MultiQubitDepolarizing" (float): Multi-qubit depolarizing
                  probability (applies if len(wires) > 1)
                All parameters default to 0.0 if not provided.

        Returns:
            None: Noise channels are applied in-place to the circuit.
        """
        if noise_params is not None:
            if isinstance(wires, int):
                wires = [wires]  # single qubit gate

            # noise on single qubits
            for wire in wires:
                bf = noise_params.get("BitFlip", 0.0)
                if bf > 0:
                    op.BitFlip(bf, wires=wire)

                pf = noise_params.get("PhaseFlip", 0.0)
                if pf > 0:
                    op.PhaseFlip(pf, wires=wire)

                dp = noise_params.get("Depolarizing", 0.0)
                if dp > 0:
                    op.DepolarizingChannel(dp, wires=wire)

            # noise on two-qubits
            if len(wires) > 1:
                p = noise_params.get("MultiQubitDepolarizing", 0.0)
                if p > 0:
                    UnitaryGates.NQubitDepolarizingChannel(p, wires)

    @staticmethod
    def GateError(
        w: Union[float, jnp.ndarray, List[float]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> Tuple[jnp.ndarray, jax.random.PRNGKey]:
        """
        Apply gate error noise to rotation angle(s).

        Adds Gaussian noise to gate rotation angles to simulate imperfect
        gate implementations.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle(s) in radians.
            noise_params (Optional[Dict[str, float]]): Dictionary with optional
                "GateError" key specifying standard deviation of Gaussian noise.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                stochastic noise generation.

        Returns:
            Tuple[jnp.ndarray, jax.random.PRNGKey]: Tuple containing:
                - Modified rotation angle(s) with applied noise
                - Updated JAX random key

        Raises:
            AssertionError: If noise_params contains "GateError" but random_key is None.
        """
        if noise_params is not None and noise_params.get("GateError", None) is not None:
            assert random_key is not None, (
                "A random_key must be provided when using GateError"
            )

            if UnitaryGates.batch_gate_error:
                random_key, sub_key = safe_random_split(random_key)
            else:
                # Use a fixed key so that every batch element (under vmap)
                # draws the same noise value, effectively broadcasting.
                sub_key = jax.random.key(0)

            w += noise_params["GateError"] * jax.random.normal(
                sub_key,
                (
                    w.shape
                    if isinstance(w, jnp.ndarray) and UnitaryGates.batch_gate_error
                    else ()
                ),
            )
        return w, random_key

    @staticmethod
    def Rot(
        phi: Union[float, jnp.ndarray, List[float]],
        theta: Union[float, jnp.ndarray, List[float]],
        omega: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply general rotation gate with optional noise.

        Applies a three-angle rotation Rot(phi, theta, omega) with optional
        gate errors and noise channels.

        Args:
            phi (Union[float, jnp.ndarray, List[float]]): First rotation angle.
            theta (Union[float, jnp.ndarray, List[float]]): Second rotation angle.
            omega (Union[float, jnp.ndarray, List[float]]): Third rotation angle.
            wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
                Supports BitFlip, PhaseFlip, Depolarizing, and GateError.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        if noise_params is not None and "GateError" in noise_params:
            phi, random_key = UnitaryGates.GateError(phi, noise_params, random_key)
            theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
            omega, random_key = UnitaryGates.GateError(omega, noise_params, random_key)
        op.Rot(phi, theta, omega, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def PauliRot(
        theta: float,
        pauli: str,
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply general rotation gate with optional noise.

        Applies a three-angle rotation Rot(phi, theta, omega) with optional
        gate errors and noise channels.

        Args:
            theta (Union[float, jnp.ndarray, List[float]]): Second rotation angle.
            pauli (str): Pauli operator to apply. Must be "X", "Y", or "Z".
            wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
                Supports BitFlip, PhaseFlip, Depolarizing, and GateError.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        if noise_params is not None and "GateError" in noise_params:
            theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
        op.PauliRot(theta, pauli, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RX(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply X-axis rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Qubit index or indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RX(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RY(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply Y-axis rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Qubit index or indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RY(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZ(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply Z-axis rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Qubit index or indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RZ(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRX(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled X-rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.CRX(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRY(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled Y-rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.CRY(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRZ(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled Z-rotation with optional noise.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.CRZ(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RXX(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply two-qubit XX rotation with optional noise.

        Implements ``RXX(theta) = exp(-i theta/2 X ⊗ X)``.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Two qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RXX(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RYY(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply two-qubit YY rotation with optional noise.

        Implements ``RYY(theta) = exp(-i theta/2 Y ⊗ Y)``.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Two qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RYY(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZZ(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply two-qubit ZZ rotation with optional noise.

        Implements ``RZZ(theta) = exp(-i theta/2 Z ⊗ Z)``.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Two qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RZZ(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZX(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply two-qubit ZX rotation with optional noise.

        Implements ``RZX(theta) = exp(-i theta/2 Z ⊗ X)``, with ``Z`` acting
        on the first wire and ``X`` on the second wire.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
            wires (Union[int, List[int]]): Two qubit indices ``[zwire, xwire]``.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.RZX(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CPhase(
        w: Union[float, jnp.ndarray, List[float]],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled phase shift gate with optional noise.

        This is a generalization of the CZ gate, applying a phase shift of
        exp(i*w) to the |11⟩ state. When w=π, this reduces to CZ.

        Args:
            w (Union[float, jnp.ndarray, List[float]]): Phase shift angle.
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        op.ControlledPhaseShift(w, wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CX(
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled-NOT (CNOT) gate with optional noise.

        Args:
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        op.CX(wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CY(
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled-Y gate with optional noise.

        Args:
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        op.CY(wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CZ(
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply controlled-Z gate with optional noise.

        Args:
            wires (Union[int, List[int]]): Control and target qubit indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        op.CZ(wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def H(
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply Hadamard gate with optional noise.

        Args:
            wires (Union[int, List[int]]): Qubit index or indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        op.H(wires=wires)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def GolombEncoding(
        w: Union[float, jnp.ndarray],
        wires: Union[int, List[int]],
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply Golomb encoding as a diagonal unitary on all given wires.

        Implements ``S(x) = exp(-i H x)`` where
        ``H = diag(g_0, g_1, ..., g_{d-1})`` and the ``g_j`` are the marks
        of a Golomb ruler of order ``d = 2^len(wires)``.  This produces a
        maximally non-degenerate Fourier spectrum with
        ``|\\Omega| = d(d-1) + 1`` distinct frequencies, each with degeneracy
        ``|R(k)| = 1``.

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

        Args:
            w: Scalar input value (the data point *x* to encode).
            wires: Qubit indices this encoding acts on.  All qubits are
                acted upon simultaneously via a single multi-qubit diagonal
                gate.
            noise_params: Optional noise parameters dictionary.
            random_key: JAX random key for stochastic noise.

        Returns:
            None: Gate and noise are applied in-place to the circuit.
        """
        wires_list = list(wires) if isinstance(wires, (list, tuple)) else [wires]
        d = 2 ** len(wires_list)
        marks = jnp.array(golomb_ruler(d), dtype=float)

        # Apply gate error to the input angle
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)

        # Build diagonal: exp(-i * mark_j * x)
        diag = jnp.exp(-1j * marks * w)

        op.DiagonalQubitUnitary(diag, wires=wires_list)
        UnitaryGates.Noise(wires_list, noise_params)

CPhase(w, wires, noise_params=None, random_key=None) staticmethod #

Apply controlled phase shift gate with optional noise.

This is a generalization of the CZ gate, applying a phase shift of exp(i*w) to the |11⟩ state. When w=π, this reduces to CZ.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Phase shift angle.

required
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CPhase(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled phase shift gate with optional noise.

    This is a generalization of the CZ gate, applying a phase shift of
    exp(i*w) to the |11⟩ state. When w=π, this reduces to CZ.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Phase shift angle.
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.ControlledPhaseShift(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CRX(w, wires, noise_params=None, random_key=None) staticmethod #

Apply controlled X-rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CRX(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled X-rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.CRX(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CRY(w, wires, noise_params=None, random_key=None) staticmethod #

Apply controlled Y-rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CRY(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled Y-rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.CRY(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CRZ(w, wires, noise_params=None, random_key=None) staticmethod #

Apply controlled Z-rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CRZ(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled Z-rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.CRZ(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CX(wires, noise_params=None, random_key=None) staticmethod #

Apply controlled-NOT (CNOT) gate with optional noise.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CX(
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled-NOT (CNOT) gate with optional noise.

    Args:
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    op.CX(wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CY(wires, noise_params=None, random_key=None) staticmethod #

Apply controlled-Y gate with optional noise.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CY(
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled-Y gate with optional noise.

    Args:
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    op.CY(wires=wires)
    UnitaryGates.Noise(wires, noise_params)

CZ(wires, noise_params=None, random_key=None) staticmethod #

Apply controlled-Z gate with optional noise.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Control and target qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def CZ(
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply controlled-Z gate with optional noise.

    Args:
        wires (Union[int, List[int]]): Control and target qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    op.CZ(wires=wires)
    UnitaryGates.Noise(wires, noise_params)

GateError(w, noise_params=None, random_key=None) staticmethod #

Apply gate error noise to rotation angle(s).

Adds Gaussian noise to gate rotation angles to simulate imperfect gate implementations.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle(s) in radians.

required
noise_params Optional[Dict[str, float]]

Dictionary with optional "GateError" key specifying standard deviation of Gaussian noise.

None
random_key Optional[PRNGKey]

JAX random key for stochastic noise generation.

None

Returns:

Type Description
Tuple[ndarray, PRNGKey]

Tuple[jnp.ndarray, jax.random.PRNGKey]: Tuple containing: - Modified rotation angle(s) with applied noise - Updated JAX random key

Raises:

Type Description
AssertionError

If noise_params contains "GateError" but random_key is None.

Source code in qml_essentials/unitary.py
@staticmethod
def GateError(
    w: Union[float, jnp.ndarray, List[float]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> Tuple[jnp.ndarray, jax.random.PRNGKey]:
    """
    Apply gate error noise to rotation angle(s).

    Adds Gaussian noise to gate rotation angles to simulate imperfect
    gate implementations.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle(s) in radians.
        noise_params (Optional[Dict[str, float]]): Dictionary with optional
            "GateError" key specifying standard deviation of Gaussian noise.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            stochastic noise generation.

    Returns:
        Tuple[jnp.ndarray, jax.random.PRNGKey]: Tuple containing:
            - Modified rotation angle(s) with applied noise
            - Updated JAX random key

    Raises:
        AssertionError: If noise_params contains "GateError" but random_key is None.
    """
    if noise_params is not None and noise_params.get("GateError", None) is not None:
        assert random_key is not None, (
            "A random_key must be provided when using GateError"
        )

        if UnitaryGates.batch_gate_error:
            random_key, sub_key = safe_random_split(random_key)
        else:
            # Use a fixed key so that every batch element (under vmap)
            # draws the same noise value, effectively broadcasting.
            sub_key = jax.random.key(0)

        w += noise_params["GateError"] * jax.random.normal(
            sub_key,
            (
                w.shape
                if isinstance(w, jnp.ndarray) and UnitaryGates.batch_gate_error
                else ()
            ),
        )
    return w, random_key

GolombEncoding(w, wires, noise_params=None, random_key=None) staticmethod #

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

Implements S(x) = exp(-i H x) where H = diag(g_0, g_1, ..., g_{d-1}) and the g_j are the marks of a Golomb ruler of order d = 2^len(wires). This produces a maximally non-degenerate Fourier spectrum with |\Omega| = d(d-1) + 1 distinct frequencies, each with degeneracy |R(k)| = 1.

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

Parameters:

Name Type Description Default
w Union[float, ndarray]

Scalar input value (the data point x to encode).

required
wires Union[int, List[int]]

Qubit indices this encoding acts on. All qubits are acted upon simultaneously via a single multi-qubit diagonal gate.

required
noise_params Optional[Dict[str, float]]

Optional noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for stochastic noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def GolombEncoding(
    w: Union[float, jnp.ndarray],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply Golomb encoding as a diagonal unitary on all given wires.

    Implements ``S(x) = exp(-i H x)`` where
    ``H = diag(g_0, g_1, ..., g_{d-1})`` and the ``g_j`` are the marks
    of a Golomb ruler of order ``d = 2^len(wires)``.  This produces a
    maximally non-degenerate Fourier spectrum with
    ``|\\Omega| = d(d-1) + 1`` distinct frequencies, each with degeneracy
    ``|R(k)| = 1``.

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

    Args:
        w: Scalar input value (the data point *x* to encode).
        wires: Qubit indices this encoding acts on.  All qubits are
            acted upon simultaneously via a single multi-qubit diagonal
            gate.
        noise_params: Optional noise parameters dictionary.
        random_key: JAX random key for stochastic noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    wires_list = list(wires) if isinstance(wires, (list, tuple)) else [wires]
    d = 2 ** len(wires_list)
    marks = jnp.array(golomb_ruler(d), dtype=float)

    # Apply gate error to the input angle
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)

    # Build diagonal: exp(-i * mark_j * x)
    diag = jnp.exp(-1j * marks * w)

    op.DiagonalQubitUnitary(diag, wires=wires_list)
    UnitaryGates.Noise(wires_list, noise_params)

H(wires, noise_params=None, random_key=None) staticmethod #

Apply Hadamard gate with optional noise.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Qubit index or indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def H(
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply Hadamard gate with optional noise.

    Args:
        wires (Union[int, List[int]]): Qubit index or indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    op.H(wires=wires)
    UnitaryGates.Noise(wires, noise_params)

NQubitDepolarizingChannel(p, wires) staticmethod #

Generate Kraus operators for n-qubit depolarizing channel.

The n-qubit depolarizing channel models uniform depolarizing noise acting on n qubits simultaneously, useful for simulating realistic multi-qubit noise affecting entangling gates.

Parameters:

Name Type Description Default
p float

Total probability of depolarizing error (0 ≤ p ≤ 1).

required
wires List[int]

Qubit indices on which the channel acts. Must contain at least 2 qubits.

required

Returns:

Type Description
QubitChannel

op.QubitChannel: QubitChannel with Kraus operators representing the depolarizing noise channel.

Raises:

Type Description
ValueError

If p is not in [0, 1] or if fewer than 2 qubits provided.

Source code in qml_essentials/unitary.py
@staticmethod
def NQubitDepolarizingChannel(p: float, wires: List[int]) -> op.QubitChannel:
    """
    Generate Kraus operators for n-qubit depolarizing channel.

    The n-qubit depolarizing channel models uniform depolarizing noise
    acting on n qubits simultaneously, useful for simulating realistic
    multi-qubit noise affecting entangling gates.

    Args:
        p (float): Total probability of depolarizing error (0 ≤ p ≤ 1).
        wires (List[int]): Qubit indices on which the channel acts.
            Must contain at least 2 qubits.

    Returns:
        op.QubitChannel: QubitChannel with Kraus operators
            representing the depolarizing noise channel.

    Raises:
        ValueError: If p is not in [0, 1] or if fewer than 2 qubits provided.
    """

    def n_qubit_depolarizing_kraus(p: float, n: int) -> List[jnp.ndarray]:
        if not (0.0 <= p <= 1.0):
            raise ValueError(f"Probability p must be between 0 and 1, got {p}")
        if n < 2:
            raise ValueError(f"Number of qubits must be >= 2, got {n}")

        Id = jnp.eye(2)
        X = op.PauliX._matrix
        Y = op.PauliY._matrix
        Z = op.PauliZ._matrix
        paulis = [Id, X, Y, Z]

        dim = 2**n
        all_ops = []

        # Generate all n-qubit Pauli tensor products:
        for indices in itertools.product(range(4), repeat=n):
            P = jnp.eye(1)
            for idx in indices:
                P = jnp.kron(P, paulis[idx])
            all_ops.append(P)

        # Identity operator corresponds to all zeros indices (Id^n)
        K0 = jnp.sqrt(1 - p * (4**n - 1) / (4**n)) * jnp.eye(dim)

        kraus_ops = []
        for i, P in enumerate(all_ops):
            if i == 0:
                # Skip the identity, already handled as K0
                continue
            kraus_ops.append(jnp.sqrt(p / (4**n)) * P)

        return [K0] + kraus_ops

    return op.QubitChannel(n_qubit_depolarizing_kraus(p, len(wires)), wires=wires)

Noise(wires, noise_params=None) staticmethod #

Apply noise channels to specified qubits.

Applies various single-qubit and multi-qubit noise channels based on the provided noise parameters dictionary.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Qubit index or list of qubit indices to apply noise to.

required
noise_params Optional[Dict[str, float]]

Dictionary of noise parameters. Supported keys: - "BitFlip" (float): Bit flip error probability - "PhaseFlip" (float): Phase flip error probability - "Depolarizing" (float): Single-qubit depolarizing probability - "MultiQubitDepolarizing" (float): Multi-qubit depolarizing probability (applies if len(wires) > 1) All parameters default to 0.0 if not provided.

None

Returns:

Name Type Description
None None

Noise channels are applied in-place to the circuit.

Source code in qml_essentials/unitary.py
@staticmethod
def Noise(
    wires: Union[int, List[int]], noise_params: Optional[Dict[str, float]] = None
) -> None:
    """
    Apply noise channels to specified qubits.

    Applies various single-qubit and multi-qubit noise channels based on
    the provided noise parameters dictionary.

    Args:
        wires (Union[int, List[int]]): Qubit index or list of qubit indices
            to apply noise to.
        noise_params (Optional[Dict[str, float]]): Dictionary of noise
            parameters. Supported keys:
            - "BitFlip" (float): Bit flip error probability
            - "PhaseFlip" (float): Phase flip error probability
            - "Depolarizing" (float): Single-qubit depolarizing probability
            - "MultiQubitDepolarizing" (float): Multi-qubit depolarizing
              probability (applies if len(wires) > 1)
            All parameters default to 0.0 if not provided.

    Returns:
        None: Noise channels are applied in-place to the circuit.
    """
    if noise_params is not None:
        if isinstance(wires, int):
            wires = [wires]  # single qubit gate

        # noise on single qubits
        for wire in wires:
            bf = noise_params.get("BitFlip", 0.0)
            if bf > 0:
                op.BitFlip(bf, wires=wire)

            pf = noise_params.get("PhaseFlip", 0.0)
            if pf > 0:
                op.PhaseFlip(pf, wires=wire)

            dp = noise_params.get("Depolarizing", 0.0)
            if dp > 0:
                op.DepolarizingChannel(dp, wires=wire)

        # noise on two-qubits
        if len(wires) > 1:
            p = noise_params.get("MultiQubitDepolarizing", 0.0)
            if p > 0:
                UnitaryGates.NQubitDepolarizingChannel(p, wires)

PauliRot(theta, pauli, wires, noise_params=None, random_key=None) staticmethod #

Apply general rotation gate with optional noise.

Applies a three-angle rotation Rot(phi, theta, omega) with optional gate errors and noise channels.

Parameters:

Name Type Description Default
theta Union[float, ndarray, List[float]]

Second rotation angle.

required
pauli str

Pauli operator to apply. Must be "X", "Y", or "Z".

required
wires Union[int, List[int]]

Qubit index or indices to apply rotation to.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary. Supports BitFlip, PhaseFlip, Depolarizing, and GateError.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def PauliRot(
    theta: float,
    pauli: str,
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply general rotation gate with optional noise.

    Applies a three-angle rotation Rot(phi, theta, omega) with optional
    gate errors and noise channels.

    Args:
        theta (Union[float, jnp.ndarray, List[float]]): Second rotation angle.
        pauli (str): Pauli operator to apply. Must be "X", "Y", or "Z".
        wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            Supports BitFlip, PhaseFlip, Depolarizing, and GateError.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    if noise_params is not None and "GateError" in noise_params:
        theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
    op.PauliRot(theta, pauli, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RX(w, wires, noise_params=None, random_key=None) staticmethod #

Apply X-axis rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Qubit index or indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RX(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply X-axis rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Qubit index or indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RX(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RXX(w, wires, noise_params=None, random_key=None) staticmethod #

Apply two-qubit XX rotation with optional noise.

Implements RXX(theta) = exp(-i theta/2 X ⊗ X).

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Two qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RXX(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply two-qubit XX rotation with optional noise.

    Implements ``RXX(theta) = exp(-i theta/2 X ⊗ X)``.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Two qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RXX(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RY(w, wires, noise_params=None, random_key=None) staticmethod #

Apply Y-axis rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Qubit index or indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RY(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply Y-axis rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Qubit index or indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RY(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RYY(w, wires, noise_params=None, random_key=None) staticmethod #

Apply two-qubit YY rotation with optional noise.

Implements RYY(theta) = exp(-i theta/2 Y ⊗ Y).

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Two qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RYY(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply two-qubit YY rotation with optional noise.

    Implements ``RYY(theta) = exp(-i theta/2 Y ⊗ Y)``.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Two qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RYY(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RZ(w, wires, noise_params=None, random_key=None) staticmethod #

Apply Z-axis rotation with optional noise.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Qubit index or indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RZ(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply Z-axis rotation with optional noise.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Qubit index or indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RZ(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RZX(w, wires, noise_params=None, random_key=None) staticmethod #

Apply two-qubit ZX rotation with optional noise.

Implements RZX(theta) = exp(-i theta/2 Z ⊗ X), with Z acting on the first wire and X on the second wire.

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Two qubit indices [zwire, xwire].

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RZX(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply two-qubit ZX rotation with optional noise.

    Implements ``RZX(theta) = exp(-i theta/2 Z ⊗ X)``, with ``Z`` acting
    on the first wire and ``X`` on the second wire.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Two qubit indices ``[zwire, xwire]``.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RZX(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

RZZ(w, wires, noise_params=None, random_key=None) staticmethod #

Apply two-qubit ZZ rotation with optional noise.

Implements RZZ(theta) = exp(-i theta/2 Z ⊗ Z).

Parameters:

Name Type Description Default
w Union[float, ndarray, List[float]]

Rotation angle.

required
wires Union[int, List[int]]

Two qubit indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def RZZ(
    w: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply two-qubit ZZ rotation with optional noise.

    Implements ``RZZ(theta) = exp(-i theta/2 Z ⊗ Z)``.

    Args:
        w (Union[float, jnp.ndarray, List[float]]): Rotation angle.
        wires (Union[int, List[int]]): Two qubit indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    op.RZZ(w, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

Rot(phi, theta, omega, wires, noise_params=None, random_key=None) staticmethod #

Apply general rotation gate with optional noise.

Applies a three-angle rotation Rot(phi, theta, omega) with optional gate errors and noise channels.

Parameters:

Name Type Description Default
phi Union[float, ndarray, List[float]]

First rotation angle.

required
theta Union[float, ndarray, List[float]]

Second rotation angle.

required
omega Union[float, ndarray, List[float]]

Third rotation angle.

required
wires Union[int, List[int]]

Qubit index or indices to apply rotation to.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary. Supports BitFlip, PhaseFlip, Depolarizing, and GateError.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None

Returns:

Name Type Description
None None

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

Source code in qml_essentials/unitary.py
@staticmethod
def Rot(
    phi: Union[float, jnp.ndarray, List[float]],
    theta: Union[float, jnp.ndarray, List[float]],
    omega: Union[float, jnp.ndarray, List[float]],
    wires: Union[int, List[int]],
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply general rotation gate with optional noise.

    Applies a three-angle rotation Rot(phi, theta, omega) with optional
    gate errors and noise channels.

    Args:
        phi (Union[float, jnp.ndarray, List[float]]): First rotation angle.
        theta (Union[float, jnp.ndarray, List[float]]): Second rotation angle.
        omega (Union[float, jnp.ndarray, List[float]]): Third rotation angle.
        wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            Supports BitFlip, PhaseFlip, Depolarizing, and GateError.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.

    Returns:
        None: Gate and noise are applied in-place to the circuit.
    """
    if noise_params is not None and "GateError" in noise_params:
        phi, random_key = UnitaryGates.GateError(phi, noise_params, random_key)
        theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
        omega, random_key = UnitaryGates.GateError(omega, noise_params, random_key)
    op.Rot(phi, theta, omega, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

Pulse Gates#

from qml_essentials.gates import PulseGates

Pulse-level implementations of quantum gates.

Implements quantum gates using time-dependent Hamiltonians and pulse sequences, following the approach from https://doi.org/10.5445/IR/1000184129. The active pulse envelope is selected via :meth:PulseInformation.set_envelope.

Attributes:

Name Type Description
omega_q

Qubit frequency (10π).

omega_c

Carrier frequency (10π).

_active_envelope str

Name of the currently active envelope shape.

Source code in qml_essentials/pulses.py
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
class PulseGates:
    """Pulse-level implementations of quantum gates.

    Implements quantum gates using time-dependent Hamiltonians and pulse
    sequences, following the approach from https://doi.org/10.5445/IR/1000184129.
    The active pulse envelope is selected via
    :meth:`PulseInformation.set_envelope`.

    Attributes:
        omega_q: Qubit frequency (10π).
        omega_c: Carrier frequency (10π).
        _active_envelope: Name of the currently active envelope shape.
    """

    # NOTE: Implementation of S, RX, RY, RZ, CZ, CNOT/CX and H pulse level
    #   gates closely follow https://doi.org/10.5445/IR/1000184129
    omega_q = 10 * jnp.pi
    omega_c = 10 * jnp.pi

    X = jnp.array([[0, 1], [1, 0]])
    Y = jnp.array([[0, -1j], [1j, 0]])
    Z = jnp.array([[1, 0], [0, -1]])

    Id = jnp.eye(2, dtype=jnp.complex64)

    _H_CZ = (jnp.pi / 4) * (
        jnp.kron(Id, Id) - jnp.kron(Z, Id) - jnp.kron(Id, Z) + jnp.kron(Z, Z)
    )

    _H_corr = jnp.pi / 2 * jnp.eye(2, dtype=jnp.complex64)

    _active_envelope: str = "gaussian"
    # Mirrors :attr:`PulseInformation._rwa`; kept here for introspection
    # of which coefficient regime the active ``_coeff_*`` functions
    # implement.  Updated by :meth:`PulseInformation.set_envelope` /
    # :meth:`PulseInformation.set_rwa`.
    _active_rwa: bool = True
    _active_frame: str = "drive"

    # Default coefficient functions for the gaussian envelope; the active
    # envelope's `set_envelope` will overwrite these.  Each gate uses two
    # coefficients (X- and Y-component of the proper interaction-picture
    # drive Hamiltonian).

    @staticmethod
    def _coeff_RX_X(p, t):
        """RX coefficient for the X term (gaussian default)."""
        t_c = t / 2
        env = PulseEnvelope.gaussian(p, t, t_c)
        carrier = jnp.cos(PulseGates.omega_c * t)
        return env * carrier * jnp.cos(PulseGates.omega_q * t) * p[-1]

    @staticmethod
    def _coeff_RX_Y(p, t):
        """RX coefficient for the Y term (gaussian default)."""
        t_c = t / 2
        env = PulseEnvelope.gaussian(p, t, t_c)
        carrier = jnp.cos(PulseGates.omega_c * t)
        return -env * carrier * jnp.sin(PulseGates.omega_q * t) * p[-1]

    @staticmethod
    def _coeff_RY_X(p, t):
        """RY coefficient for the X term (gaussian default)."""
        t_c = t / 2
        env = PulseEnvelope.gaussian(p, t, t_c)
        carrier = jnp.cos(PulseGates.omega_c * t + jnp.pi / 2)
        return env * carrier * jnp.cos(PulseGates.omega_q * t) * p[-1]

    @staticmethod
    def _coeff_RY_Y(p, t):
        """RY coefficient for the Y term (gaussian default)."""
        t_c = t / 2
        env = PulseEnvelope.gaussian(p, t, t_c)
        carrier = jnp.cos(PulseGates.omega_c * t + jnp.pi / 2)
        return -env * carrier * jnp.sin(PulseGates.omega_q * t) * p[-1]

    # Backward-compat aliases (resolve to the dominant component of each gate).
    _coeff_Sx = _coeff_RX_X
    _coeff_Sy = _coeff_RY_Y

    @staticmethod
    def _coeff_Sz(p, t):
        """Coefficient function for RZ pulse: p * w."""
        return p[0] * p[1]

    @staticmethod
    def _coeff_Sc(p, t):
        """Constant coefficient for H correction phase."""
        return -1.0

    @staticmethod
    def _coeff_Scz(p, t):
        """Coefficient function for CZ pulse."""
        return p * jnp.pi

    @staticmethod
    def _record_pulse_event(gate_name, w, wires, pulse_params, parent=None):
        """Append a PulseEvent to the active pulse tape if recording.

        This is called from leaf gate methods (RX, RY, RZ, CZ) so that
        :func:`~qml_essentials.tape.pulse_recording` can collect events
        without the caller needing to know about the tape.
        """
        ptape = active_pulse_tape()
        if ptape is None:
            return

        from qml_essentials.drawing import PulseEvent, LEAF_META

        meta = LEAF_META.get(gate_name, {})
        wires_list = [wires] if isinstance(wires, int) else list(wires)

        if meta.get("physical", False):
            info = PulseEnvelope.get(PulseInformation.get_envelope())
            pp = PulseInformation.gate_by_name(gate_name).split_params(pulse_params)
            env_p = pp[:-1]
            dur = float(pp[-1])
            ptape.append(
                PulseEvent(
                    gate=gate_name,
                    wires=wires_list,
                    envelope_fn=info["fn"],
                    envelope_params=jnp.array(env_p),
                    w=float(w),
                    duration=dur,
                    carrier_phase=meta["carrier_phase"],
                    parent=parent,
                )
            )
        else:
            pp = PulseInformation.gate_by_name(gate_name).split_params(pulse_params)
            ptape.append(
                PulseEvent(
                    gate=gate_name,
                    wires=wires_list,
                    envelope_fn=None,
                    envelope_params=jnp.ravel(jnp.asarray(pp)),
                    w=float(w) if not isinstance(w, list) else 0.0,
                    duration=1.0,
                    carrier_phase=0.0,
                    parent=parent,
                )
            )

    @staticmethod
    def Rot(
        phi: float,
        theta: float,
        omega: float,
        wires: Union[int, List[int]],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply general rotation via decomposition: RZ(phi) · RY(theta) · RZ(omega).

        Args:
            phi (float): First rotation angle.
            theta (float): Second rotation angle.
            omega (float): Third rotation angle.
            wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility

        Returns:
            None: Gates are applied in-place to the circuit.
        """
        if noise_params is not None and "GateError" in noise_params:
            phi, random_key = UnitaryGates.GateError(phi, noise_params, random_key)
            theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
            omega, random_key = UnitaryGates.GateError(omega, noise_params, random_key)
        PulseGates._execute_composite("Rot", [phi, theta, omega], wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def PauliRot(
        pauli: str,
        theta: float,
        wires: Union[int, List[int]],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Not implemented as a PulseGate."""
        raise NotImplementedError("PauliRot gate is not implemented as PulseGate")

    @staticmethod
    def RX(
        w: float,
        wires: Union[int, List[int]],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply X-axis rotation using the active pulse envelope.

        Args:
            w: Rotation angle in radians.
            wires: Qubit index or indices.
            pulse_params: Envelope parameters ``[env_0, ..., env_n, t]``.
                If ``None``, uses optimized defaults.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
        """
        pulse_params = PulseInformation.RX.split_params(pulse_params)

        PulseGates._record_pulse_event("RX", w, wires, pulse_params)
        t = pulse_params[-1]

        # Proper interaction-picture drive Hamiltonian for RX:
        #   H_I(τ) = Ω(τ)·cos(ω_c·τ) · [ cos(ω_q·τ)·X − sin(ω_q·τ)·Y ]
        # which on resonance averages (RWA) to +(Ω/2)·X while the
        # 2·ω_q counter-rotating part oscillates and cancels.
        H_X = js.Hamiltonian(PulseGates.X, wires=wires)
        H_Y = js.Hamiltonian(PulseGates.Y, wires=wires)
        H_eff = PulseGates._coeff_RX_X * H_X + PulseGates._coeff_RX_Y * H_Y

        # Pack: [envelope_params..., w] - evolution time is the last element
        # of pulse_params (pulse_params[-1]).
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        # Use jnp.concatenate over Python list-splat to keep the trace graph
        # compact (no per-element unpacking + restack).
        env_params = jnp.concatenate(
            [jnp.ravel(pulse_params[:-1]), jnp.ravel(jnp.asarray(w))]
        )
        # Both terms share the same parameter array.
        H_eff.evolve(name="RX")([env_params, env_params], t)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RY(
        w: float,
        wires: Union[int, List[int]],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply Y-axis rotation using the active pulse envelope.

        Args:
            w: Rotation angle in radians.
            wires: Qubit index or indices.
            pulse_params: Envelope parameters ``[env_0, ..., env_n, t]``.
                If ``None``, uses optimized defaults.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
        """
        pulse_params = PulseInformation.RY.split_params(pulse_params)

        PulseGates._record_pulse_event("RY", w, wires, pulse_params)
        t = pulse_params[-1]

        # See NOTE in RX: same proper interaction-picture form, with
        # carrier phase ϕ = +π/2 so the slow RWA component drives +Y.
        H_X = js.Hamiltonian(PulseGates.X, wires=wires)
        H_Y = js.Hamiltonian(PulseGates.Y, wires=wires)
        H_eff = PulseGates._coeff_RY_X * H_X + PulseGates._coeff_RY_Y * H_Y

        # Pack w into the params so the coefficient function doesn't need
        # a closure - this enables JIT solver cache sharing across all RY calls.
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        env_params = jnp.concatenate(
            [jnp.ravel(pulse_params[:-1]), jnp.ravel(jnp.asarray(w))]
        )
        H_eff.evolve(name="RY")([env_params, env_params], t)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZ(
        w: float,
        wires: Union[int, List[int]],
        pulse_params: Optional[float] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """
        Apply Z-axis rotation using pulse-level implementation.

        Implements RZ rotation using virtual Z rotations (phase tracking)
        without physical pulse application.

        Args:
            w (float): Rotation angle in radians.
            wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
            pulse_params (Optional[float]): Duration parameter for the pulse.
                Rotation angle = w * 2 * pulse_params. Defaults to 0.5 if None.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility

        Returns:
            None: Gate is applied in-place to the circuit.
        """
        pulse_params = PulseInformation.RZ.split_params(pulse_params)

        PulseGates._record_pulse_event("RZ", w, wires, pulse_params)

        _H = js.Hamiltonian(PulseGates.Z, wires=wires)
        H_eff = PulseGates._coeff_Sz * _H

        # Pack w into the params so the coefficient function doesn't need
        # a closure - [pulse_param_scalar, w] enables JIT solver cache sharing.
        # pulse_params may be a 1-element array or scalar; ravel + slice the first
        # element to preserve the original semantics, then concatenate with w.
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        pp_flat = jnp.ravel(jnp.asarray(pulse_params))
        H_eff.evolve(name="RZ")(
            [jnp.concatenate([pp_flat[:1], jnp.ravel(jnp.asarray(w))])],
            1,
        )

        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def _resolve_wires(wire_fn, wires):
        """Resolve a wire selector string to actual wire(s).

        Args:
            wire_fn: ``"all"``, ``"target"``, or ``"control"``.
            wires: Parent gate's wire(s) (int or list).

        Returns:
            Wire(s) for the child gate.
        """
        wires_list = [wires] if isinstance(wires, int) else list(wires)
        if wire_fn == "all":
            return wires if len(wires_list) > 1 else wires_list[0]
        if wire_fn == "target":
            return wires_list[-1] if len(wires_list) > 1 else wires_list[0]
        if wire_fn == "control":
            return wires_list[0]
        raise ValueError(f"Unknown wire_fn: {wire_fn!r}")

    @staticmethod
    def _execute_composite(gate_name, w, wires, pulse_params=None):
        """Execute a composite gate by walking its decomposition.

        Reads the :class:`DecompositionStep` list from
        :class:`PulseInformation` and dispatches each step to the
        appropriate ``PulseGates`` method.

        Args:
            gate_name: Gate name (e.g. ``"H"``, ``"CX"``).
            w: Rotation angle(s) passed to the parent gate.
            wires: Wire(s) of the parent gate.
            pulse_params: Optional pulse parameters (split across children).
        """
        pp_obj = PulseInformation.gate_by_name(gate_name)
        parts = pp_obj.split_params(pulse_params)

        for step, child_params in zip(pp_obj.decomposition, parts):
            child_wires = PulseGates._resolve_wires(step.wire_fn, wires)
            child_w = step.angle_fn(w) if step.angle_fn is not None else w
            child_gate = getattr(PulseGates, step.gate.name)

            # Leaf gates that take a rotation angle
            if step.gate.name in ("RX", "RY", "RZ"):
                child_gate(child_w, wires=child_wires, pulse_params=child_params)
            # Leaf gates without a rotation angle
            elif step.gate.name in ("CZ",):
                child_gate(wires=child_wires, pulse_params=child_params)
            # Composite gates with a rotation angle (CRX, CRY, CRZ, Rot, ...)
            elif step.gate.name in ("Rot",):
                # Rot expects (phi, theta, omega, wires, ...)
                child_gate(*child_w, wires=child_wires, pulse_params=child_params)
            elif step.gate.decomposition is not None and step.gate.name in (
                "CRX",
                "CRY",
                "CRZ",
                "CPhase",
                "RXX",
                "RYY",
                "RZZ",
                "RZX",
            ):
                child_gate(child_w, wires=child_wires, pulse_params=child_params)
            # Other composite gates (H, CX, CY, ...)
            else:
                child_gate(wires=child_wires, pulse_params=child_params)

    @staticmethod
    def H(
        wires: Union[int, List[int]],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply Hadamard gate using pulse decomposition.

        Decomposes as RZ(π) · RY(π/2) followed by a correction phase.

        Args:
            wires (Union[int, List[int]]): Qubit index or indices.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).
        """
        PulseGates._execute_composite("H", 0.0, wires, pulse_params)

        # Correction phase unique to the H gate
        _H = js.Hamiltonian(PulseGates._H_corr, wires=wires)
        H_corr = PulseGates._coeff_Sc * _H
        H_corr.evolve(name="H")([0], 1)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CX(
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply CNOT gate via decomposition: H(target) · CZ · H(target).

        Args:
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        Returns:
            None: Gate is applied in-place to the circuit.
        """
        PulseGates._execute_composite("CX", 0.0, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CY(
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled-Y via decomposition.

        Args:
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        """
        PulseGates._execute_composite("CY", 0.0, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CZ(
        wires: List[int],
        pulse_params: Optional[float] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled-Z using ZZ coupling Hamiltonian.

        Args:
            wires (List[int]): Control and target qubit indices.
            pulse_params (Optional[float]): Time or duration parameter for
                the pulse evolution. If None, uses optimized value.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).

        """
        if pulse_params is None:
            pulse_params = PulseInformation.CZ.params

        PulseGates._record_pulse_event("CZ", 0.0, wires, pulse_params)

        _H = js.Hamiltonian(PulseGates._H_CZ, wires=wires)
        H_eff = PulseGates._coeff_Scz * _H
        H_eff.evolve(name="CZ")([pulse_params], 1)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRX(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled-RX via decomposition.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
                (not used in this gate).
        """
        PulseGates._execute_composite("CRX", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRY(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled-RY via decomposition.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("CRY", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CRZ(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled-RZ via decomposition.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("CRZ", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def CPhase(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply controlled phase shift via decomposition.

        Decomposes CPhase(φ) into RZ and CX gates:
        RZ(φ/2) on control, RZ(φ/2) on target, CX, RZ(-φ/2) on target, CX.

        Args:
            w (float): Phase shift angle in radians.
            wires (List[int]): Control and target qubit indices [control, target].
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("CPhase", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RXX(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply two-qubit RXX rotation via decomposition.

        Implements ``RXX(theta) = exp(-i theta/2 X ⊗ X)`` as
        ``(H ⊗ H) · RZZ(theta) · (H ⊗ H)``.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Two qubit indices.
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("RXX", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RYY(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply two-qubit RYY rotation via decomposition.

        Implements ``RYY(theta) = exp(-i theta/2 Y ⊗ Y)`` by conjugating the
        RZZ skeleton with ``RX(pi/2)`` rotations on both wires.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Two qubit indices.
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("RYY", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZZ(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply two-qubit RZZ rotation via decomposition.

        Implements ``RZZ(theta) = exp(-i theta/2 Z ⊗ Z)`` as
        ``CX · RZ(theta)_target · CX``.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Two qubit indices.
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("RZZ", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

    @staticmethod
    def RZX(
        w: float,
        wires: List[int],
        pulse_params: Optional[jnp.ndarray] = None,
        noise_params: Optional[Dict[str, float]] = None,
        random_key: Optional[jax.random.PRNGKey] = None,
    ) -> None:
        """Apply two-qubit RZX rotation via decomposition.

        Implements ``RZX(theta) = exp(-i theta/2 Z ⊗ X)`` (Z on the first
        wire, X on the second) by conjugating the RZZ skeleton with a
        Hadamard on the target wire.

        Args:
            w (float): Rotation angle in radians.
            wires (List[int]): Two qubit indices ``[zwire, xwire]``.
            pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
                composing gates. If None, uses optimized parameters.
            noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
        """
        w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
        PulseGates._execute_composite("RZX", w, wires, pulse_params)
        UnitaryGates.Noise(wires, noise_params)

CPhase(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled phase shift via decomposition.

Decomposes CPhase(φ) into RZ and CX gates: RZ(φ/2) on control, RZ(φ/2) on target, CX, RZ(-φ/2) on target, CX.

Parameters:

Name Type Description Default
w float

Phase shift angle in radians.

required
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None
Source code in qml_essentials/pulses.py
@staticmethod
def CPhase(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled phase shift via decomposition.

    Decomposes CPhase(φ) into RZ and CX gates:
    RZ(φ/2) on control, RZ(φ/2) on target, CX, RZ(-φ/2) on target, CX.

    Args:
        w (float): Phase shift angle in radians.
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("CPhase", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CRX(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled-RX via decomposition.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None
Source code in qml_essentials/pulses.py
@staticmethod
def CRX(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled-RX via decomposition.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).
    """
    PulseGates._execute_composite("CRX", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CRY(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled-RY via decomposition.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None
Source code in qml_essentials/pulses.py
@staticmethod
def CRY(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled-RY via decomposition.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("CRY", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CRZ(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled-RZ via decomposition.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None
Source code in qml_essentials/pulses.py
@staticmethod
def CRZ(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled-RZ via decomposition.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("CRZ", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CX(wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply CNOT gate via decomposition: H(target) · CZ · H(target).

Parameters:

Name Type Description Default
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None

Returns:

Name Type Description
None None

Gate is applied in-place to the circuit.

Source code in qml_essentials/pulses.py
@staticmethod
def CX(
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply CNOT gate via decomposition: H(target) · CZ · H(target).

    Args:
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    Returns:
        None: Gate is applied in-place to the circuit.
    """
    PulseGates._execute_composite("CX", 0.0, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CY(wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled-Y via decomposition.

Parameters:

Name Type Description Default
wires List[int]

Control and target qubit indices [control, target].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None
Source code in qml_essentials/pulses.py
@staticmethod
def CY(
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled-Y via decomposition.

    Args:
        wires (List[int]): Control and target qubit indices [control, target].
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    """
    PulseGates._execute_composite("CY", 0.0, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

CZ(wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply controlled-Z using ZZ coupling Hamiltonian.

Parameters:

Name Type Description Default
wires List[int]

Control and target qubit indices.

required
pulse_params Optional[float]

Time or duration parameter for the pulse evolution. If None, uses optimized value.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None
Source code in qml_essentials/pulses.py
@staticmethod
def CZ(
    wires: List[int],
    pulse_params: Optional[float] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply controlled-Z using ZZ coupling Hamiltonian.

    Args:
        wires (List[int]): Control and target qubit indices.
        pulse_params (Optional[float]): Time or duration parameter for
            the pulse evolution. If None, uses optimized value.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).

    """
    if pulse_params is None:
        pulse_params = PulseInformation.CZ.params

    PulseGates._record_pulse_event("CZ", 0.0, wires, pulse_params)

    _H = js.Hamiltonian(PulseGates._H_CZ, wires=wires)
    H_eff = PulseGates._coeff_Scz * _H
    H_eff.evolve(name="CZ")([pulse_params], 1)
    UnitaryGates.Noise(wires, noise_params)

H(wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply Hadamard gate using pulse decomposition.

Decomposes as RZ(π) · RY(π/2) followed by a correction phase.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Qubit index or indices.

required
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility (not used in this gate).

None
Source code in qml_essentials/pulses.py
@staticmethod
def H(
    wires: Union[int, List[int]],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply Hadamard gate using pulse decomposition.

    Decomposes as RZ(π) · RY(π/2) followed by a correction phase.

    Args:
        wires (Union[int, List[int]]): Qubit index or indices.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
            (not used in this gate).
    """
    PulseGates._execute_composite("H", 0.0, wires, pulse_params)

    # Correction phase unique to the H gate
    _H = js.Hamiltonian(PulseGates._H_corr, wires=wires)
    H_corr = PulseGates._coeff_Sc * _H
    H_corr.evolve(name="H")([0], 1)
    UnitaryGates.Noise(wires, noise_params)

PauliRot(pauli, theta, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Not implemented as a PulseGate.

Source code in qml_essentials/pulses.py
@staticmethod
def PauliRot(
    pauli: str,
    theta: float,
    wires: Union[int, List[int]],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Not implemented as a PulseGate."""
    raise NotImplementedError("PauliRot gate is not implemented as PulseGate")

RX(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply X-axis rotation using the active pulse envelope.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires Union[int, List[int]]

Qubit index or indices.

required
pulse_params Optional[ndarray]

Envelope parameters [env_0, ..., env_n, t]. If None, uses optimized defaults.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None
Source code in qml_essentials/pulses.py
@staticmethod
def RX(
    w: float,
    wires: Union[int, List[int]],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply X-axis rotation using the active pulse envelope.

    Args:
        w: Rotation angle in radians.
        wires: Qubit index or indices.
        pulse_params: Envelope parameters ``[env_0, ..., env_n, t]``.
            If ``None``, uses optimized defaults.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
    """
    pulse_params = PulseInformation.RX.split_params(pulse_params)

    PulseGates._record_pulse_event("RX", w, wires, pulse_params)
    t = pulse_params[-1]

    # Proper interaction-picture drive Hamiltonian for RX:
    #   H_I(τ) = Ω(τ)·cos(ω_c·τ) · [ cos(ω_q·τ)·X − sin(ω_q·τ)·Y ]
    # which on resonance averages (RWA) to +(Ω/2)·X while the
    # 2·ω_q counter-rotating part oscillates and cancels.
    H_X = js.Hamiltonian(PulseGates.X, wires=wires)
    H_Y = js.Hamiltonian(PulseGates.Y, wires=wires)
    H_eff = PulseGates._coeff_RX_X * H_X + PulseGates._coeff_RX_Y * H_Y

    # Pack: [envelope_params..., w] - evolution time is the last element
    # of pulse_params (pulse_params[-1]).
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    # Use jnp.concatenate over Python list-splat to keep the trace graph
    # compact (no per-element unpacking + restack).
    env_params = jnp.concatenate(
        [jnp.ravel(pulse_params[:-1]), jnp.ravel(jnp.asarray(w))]
    )
    # Both terms share the same parameter array.
    H_eff.evolve(name="RX")([env_params, env_params], t)
    UnitaryGates.Noise(wires, noise_params)

RXX(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply two-qubit RXX rotation via decomposition.

Implements RXX(theta) = exp(-i theta/2 X ⊗ X) as (H ⊗ H) · RZZ(theta) · (H ⊗ H).

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Two qubit indices.

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None
Source code in qml_essentials/pulses.py
@staticmethod
def RXX(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply two-qubit RXX rotation via decomposition.

    Implements ``RXX(theta) = exp(-i theta/2 X ⊗ X)`` as
    ``(H ⊗ H) · RZZ(theta) · (H ⊗ H)``.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Two qubit indices.
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("RXX", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

RY(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply Y-axis rotation using the active pulse envelope.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires Union[int, List[int]]

Qubit index or indices.

required
pulse_params Optional[ndarray]

Envelope parameters [env_0, ..., env_n, t]. If None, uses optimized defaults.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None
Source code in qml_essentials/pulses.py
@staticmethod
def RY(
    w: float,
    wires: Union[int, List[int]],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply Y-axis rotation using the active pulse envelope.

    Args:
        w: Rotation angle in radians.
        wires: Qubit index or indices.
        pulse_params: Envelope parameters ``[env_0, ..., env_n, t]``.
            If ``None``, uses optimized defaults.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility
    """
    pulse_params = PulseInformation.RY.split_params(pulse_params)

    PulseGates._record_pulse_event("RY", w, wires, pulse_params)
    t = pulse_params[-1]

    # See NOTE in RX: same proper interaction-picture form, with
    # carrier phase ϕ = +π/2 so the slow RWA component drives +Y.
    H_X = js.Hamiltonian(PulseGates.X, wires=wires)
    H_Y = js.Hamiltonian(PulseGates.Y, wires=wires)
    H_eff = PulseGates._coeff_RY_X * H_X + PulseGates._coeff_RY_Y * H_Y

    # Pack w into the params so the coefficient function doesn't need
    # a closure - this enables JIT solver cache sharing across all RY calls.
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    env_params = jnp.concatenate(
        [jnp.ravel(pulse_params[:-1]), jnp.ravel(jnp.asarray(w))]
    )
    H_eff.evolve(name="RY")([env_params, env_params], t)
    UnitaryGates.Noise(wires, noise_params)

RYY(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply two-qubit RYY rotation via decomposition.

Implements RYY(theta) = exp(-i theta/2 Y ⊗ Y) by conjugating the RZZ skeleton with RX(pi/2) rotations on both wires.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Two qubit indices.

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None
Source code in qml_essentials/pulses.py
@staticmethod
def RYY(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply two-qubit RYY rotation via decomposition.

    Implements ``RYY(theta) = exp(-i theta/2 Y ⊗ Y)`` by conjugating the
    RZZ skeleton with ``RX(pi/2)`` rotations on both wires.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Two qubit indices.
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("RYY", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

RZ(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply Z-axis rotation using pulse-level implementation.

Implements RZ rotation using virtual Z rotations (phase tracking) without physical pulse application.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires Union[int, List[int]]

Qubit index or indices to apply rotation to.

required
pulse_params Optional[float]

Duration parameter for the pulse. Rotation angle = w * 2 * pulse_params. Defaults to 0.5 if None.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None

Returns:

Name Type Description
None None

Gate is applied in-place to the circuit.

Source code in qml_essentials/pulses.py
@staticmethod
def RZ(
    w: float,
    wires: Union[int, List[int]],
    pulse_params: Optional[float] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply Z-axis rotation using pulse-level implementation.

    Implements RZ rotation using virtual Z rotations (phase tracking)
    without physical pulse application.

    Args:
        w (float): Rotation angle in radians.
        wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
        pulse_params (Optional[float]): Duration parameter for the pulse.
            Rotation angle = w * 2 * pulse_params. Defaults to 0.5 if None.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility

    Returns:
        None: Gate is applied in-place to the circuit.
    """
    pulse_params = PulseInformation.RZ.split_params(pulse_params)

    PulseGates._record_pulse_event("RZ", w, wires, pulse_params)

    _H = js.Hamiltonian(PulseGates.Z, wires=wires)
    H_eff = PulseGates._coeff_Sz * _H

    # Pack w into the params so the coefficient function doesn't need
    # a closure - [pulse_param_scalar, w] enables JIT solver cache sharing.
    # pulse_params may be a 1-element array or scalar; ravel + slice the first
    # element to preserve the original semantics, then concatenate with w.
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    pp_flat = jnp.ravel(jnp.asarray(pulse_params))
    H_eff.evolve(name="RZ")(
        [jnp.concatenate([pp_flat[:1], jnp.ravel(jnp.asarray(w))])],
        1,
    )

    UnitaryGates.Noise(wires, noise_params)

RZX(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply two-qubit RZX rotation via decomposition.

Implements RZX(theta) = exp(-i theta/2 Z ⊗ X) (Z on the first wire, X on the second) by conjugating the RZZ skeleton with a Hadamard on the target wire.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Two qubit indices [zwire, xwire].

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None
Source code in qml_essentials/pulses.py
@staticmethod
def RZX(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply two-qubit RZX rotation via decomposition.

    Implements ``RZX(theta) = exp(-i theta/2 Z ⊗ X)`` (Z on the first
    wire, X on the second) by conjugating the RZZ skeleton with a
    Hadamard on the target wire.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Two qubit indices ``[zwire, xwire]``.
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("RZX", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

RZZ(w, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply two-qubit RZZ rotation via decomposition.

Implements RZZ(theta) = exp(-i theta/2 Z ⊗ Z) as CX · RZ(theta)_target · CX.

Parameters:

Name Type Description Default
w float

Rotation angle in radians.

required
wires List[int]

Two qubit indices.

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for noise.

None
Source code in qml_essentials/pulses.py
@staticmethod
def RZZ(
    w: float,
    wires: List[int],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """Apply two-qubit RZZ rotation via decomposition.

    Implements ``RZZ(theta) = exp(-i theta/2 Z ⊗ Z)`` as
    ``CX · RZ(theta)_target · CX``.

    Args:
        w (float): Rotation angle in radians.
        wires (List[int]): Two qubit indices.
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for noise.
    """
    w, random_key = UnitaryGates.GateError(w, noise_params, random_key)
    PulseGates._execute_composite("RZZ", w, wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

Rot(phi, theta, omega, wires, pulse_params=None, noise_params=None, random_key=None) staticmethod #

Apply general rotation via decomposition: RZ(phi) · RY(theta) · RZ(omega).

Parameters:

Name Type Description Default
phi float

First rotation angle.

required
theta float

Second rotation angle.

required
omega float

Third rotation angle.

required
wires Union[int, List[int]]

Qubit index or indices to apply rotation to.

required
pulse_params Optional[ndarray]

Pulse parameters for the composing gates. If None, uses optimized parameters.

None
noise_params Optional[Dict[str, float]]

Noise parameters dictionary.

None
random_key Optional[PRNGKey]

JAX random key for compatibility

None

Returns:

Name Type Description
None None

Gates are applied in-place to the circuit.

Source code in qml_essentials/pulses.py
@staticmethod
def Rot(
    phi: float,
    theta: float,
    omega: float,
    wires: Union[int, List[int]],
    pulse_params: Optional[jnp.ndarray] = None,
    noise_params: Optional[Dict[str, float]] = None,
    random_key: Optional[jax.random.PRNGKey] = None,
) -> None:
    """
    Apply general rotation via decomposition: RZ(phi) · RY(theta) · RZ(omega).

    Args:
        phi (float): First rotation angle.
        theta (float): Second rotation angle.
        omega (float): Third rotation angle.
        wires (Union[int, List[int]]): Qubit index or indices to apply rotation to.
        pulse_params (Optional[jnp.ndarray]): Pulse parameters for the
            composing gates. If None, uses optimized parameters.
        noise_params (Optional[Dict[str, float]]): Noise parameters dictionary.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for compatibility

    Returns:
        None: Gates are applied in-place to the circuit.
    """
    if noise_params is not None and "GateError" in noise_params:
        phi, random_key = UnitaryGates.GateError(phi, noise_params, random_key)
        theta, random_key = UnitaryGates.GateError(theta, noise_params, random_key)
        omega, random_key = UnitaryGates.GateError(omega, noise_params, random_key)
    PulseGates._execute_composite("Rot", [phi, theta, omega], wires, pulse_params)
    UnitaryGates.Noise(wires, noise_params)

Pulse Structure#

from qml_essentials.gates import PulseParams

Container for hierarchical pulse parameters.

Leaf nodes hold direct parameters; composite nodes hold a list of :class:DecompositionStep objects that describe how the gate is built from simpler gates.

Attributes:

Name Type Description
name

Gate identifier (e.g. "RX", "H").

decomposition

List of :class:DecompositionStep (composite only).

Source code in qml_essentials/pulses.py
class PulseParams:
    """Container for hierarchical pulse parameters.

    Leaf nodes hold direct parameters; composite nodes hold a list of
    :class:`DecompositionStep` objects that describe how the gate is
    built from simpler gates.

    Attributes:
        name: Gate identifier (e.g. ``"RX"``, ``"H"``).
        decomposition: List of :class:`DecompositionStep` (composite only).
    """

    def __init__(
        self,
        name: str = "",
        params: Optional[jnp.ndarray] = None,
        decomposition: Optional[List[DecompositionStep]] = None,
    ) -> None:
        """
        Args:
            name: Gate name.
            params: Direct pulse parameters (leaf gates).
                Mutually exclusive with *decomposition*.
            decomposition: List of :class:`DecompositionStep` (composite gates).
                Mutually exclusive with *params*.
        """
        assert (params is None) != (decomposition is None), (
            "Exactly one of `params` or `decomposition` must be provided."
        )

        self.decomposition = decomposition
        # Derive _pulse_obj for backward compat with childs/leafs/split_params
        self._pulse_obj = (
            [step.gate for step in decomposition] if decomposition else None
        )

        if params is not None:
            self._params = params

        self.name = name

    def __len__(self) -> int:
        """
        Get the total number of pulse parameters.

        For composite gates, returns the accumulated count from all children.

        Returns:
            int: Total number of pulse parameters.
        """
        return len(self.params)

    def __getitem__(self, idx: int) -> Union[float, jnp.ndarray]:
        """
        Access pulse parameter(s) by index.

        For leaf gates, returns the parameter at the given index.
        For composite gates, returns parameters of the child at the given index.

        Args:
            idx (int): Index to access.

        Returns:
            Union[float, jnp.ndarray]: Parameter value or child parameters.
        """
        if self.is_leaf:
            return self.params[idx]
        else:
            return self.childs[idx].params

    def __str__(self) -> str:
        """Return string representation (gate name)."""
        return self.name

    def __repr__(self) -> str:
        """Return repr string (gate name)."""
        return self.name

    @property
    def is_leaf(self) -> bool:
        """Check if this is a leaf node (direct parameters, no children)."""
        return self._pulse_obj is None

    @property
    def size(self) -> int:
        """Get the total parameter count (alias for __len__)."""
        return len(self)

    @property
    def leafs(self) -> List["PulseParams"]:
        """
        Get all leaf nodes in the hierarchy.

        Recursively collects all leaf PulseParams objects in the tree.

        Returns:
            List[PulseParams]: List of unique leaf nodes.
        """
        if self.is_leaf:
            return [self]

        leafs = []
        for obj in self._pulse_obj:
            leafs.extend(obj.leafs)

        return list(set(leafs))

    @property
    def childs(self) -> List["PulseParams"]:
        """
        Get direct children of this node.

        Returns:
            List[PulseParams]: List of child PulseParams objects, or empty list
                if this is a leaf node.
        """
        if self.is_leaf:
            return []

        return self._pulse_obj

    @property
    def shape(self) -> List[int]:
        """
        Get the shape of pulse parameters.

        For leaf nodes, returns list with parameter count.
        For composite nodes, returns nested list of child shapes.

        Returns:
            List[int]: Parameter shape specification.
        """
        if self.is_leaf:
            return [len(self.params)]

        shape = []
        for obj in self.childs:
            shape.append(*obj.shape())

        return shape

    @property
    def params(self) -> jnp.ndarray:
        """
        Get or compute pulse parameters.

        For leaf nodes, returns internal pulse parameters.
        For composite nodes, returns concatenated parameters from all children.

        Returns:
            jnp.ndarray: Pulse parameters array.
        """
        if self.is_leaf:
            return self._params

        params = self.split_params(params=None, leafs=False)

        return jnp.concatenate(params)

    @params.setter
    def params(self, value: jnp.ndarray) -> None:
        """
        Set pulse parameters.

        For leaf nodes, sets internal parameters directly.
        For composite nodes, distributes values across children.

        Args:
            value (jnp.ndarray): Pulse parameters to set.

        Raises:
            AssertionError: If value is not jnp.ndarray for leaf nodes.
        """
        if self.is_leaf:
            assert isinstance(value, jnp.ndarray), "params must be a jnp.ndarray"
            self._params = value
            return

        idx = 0
        for obj in self.childs:
            nidx = idx + obj.size
            obj.params = value[idx:nidx]
            idx = nidx

    @property
    def leaf_params(self) -> jnp.ndarray:
        """
        Get parameters from all leaf nodes.

        Returns:
            jnp.ndarray: Concatenated parameters from all leaf nodes.
        """
        if self.is_leaf:
            return self._params

        params = self.split_params(None, leafs=True)

        return jnp.concatenate(params)

    @leaf_params.setter
    def leaf_params(self, value: jnp.ndarray) -> None:
        """
        Set parameters for all leaf nodes.

        Args:
            value (jnp.ndarray): Parameters to distribute across leaf nodes.
        """
        if self.is_leaf:
            self._params = value
            return

        idx = 0
        for obj in self.leafs:
            nidx = idx + obj.size
            obj.params = value[idx:nidx]
            idx = nidx

    def split_params(
        self,
        params: Optional[jnp.ndarray] = None,
        leafs: bool = False,
    ) -> List[jnp.ndarray]:
        """
        Split parameters into sub-arrays for children or leaves.

        Args:
            params (Optional[jnp.ndarray]): Parameters to split. If None,
                uses internal parameters.
            leafs (bool): If True, splits across leaf nodes; if False,
                splits across direct children. Defaults to False.

        Returns:
            List[jnp.ndarray]: List of parameter arrays for children or leaves.
        """
        if params is None:
            if self.is_leaf:
                return self._params

            objs = self.leafs if leafs else self.childs
            s_params = []
            for obj in objs:
                s_params.append(obj.params)

            return s_params
        else:
            if self.is_leaf:
                return params

            objs = self.leafs if leafs else self.childs
            s_params = []
            idx = 0
            for obj in objs:
                nidx = idx + obj.size
                s_params.append(params[idx:nidx])
                idx = nidx

            return s_params

childs property #

Get direct children of this node.

Returns:

Type Description
List[PulseParams]

List[PulseParams]: List of child PulseParams objects, or empty list if this is a leaf node.

is_leaf property #

Check if this is a leaf node (direct parameters, no children).

leaf_params property writable #

Get parameters from all leaf nodes.

Returns:

Type Description
ndarray

jnp.ndarray: Concatenated parameters from all leaf nodes.

leafs property #

Get all leaf nodes in the hierarchy.

Recursively collects all leaf PulseParams objects in the tree.

Returns:

Type Description
List[PulseParams]

List[PulseParams]: List of unique leaf nodes.

params property writable #

Get or compute pulse parameters.

For leaf nodes, returns internal pulse parameters. For composite nodes, returns concatenated parameters from all children.

Returns:

Type Description
ndarray

jnp.ndarray: Pulse parameters array.

shape property #

Get the shape of pulse parameters.

For leaf nodes, returns list with parameter count. For composite nodes, returns nested list of child shapes.

Returns:

Type Description
List[int]

List[int]: Parameter shape specification.

size property #

Get the total parameter count (alias for len).

__getitem__(idx) #

Access pulse parameter(s) by index.

For leaf gates, returns the parameter at the given index. For composite gates, returns parameters of the child at the given index.

Parameters:

Name Type Description Default
idx int

Index to access.

required

Returns:

Type Description
Union[float, ndarray]

Union[float, jnp.ndarray]: Parameter value or child parameters.

Source code in qml_essentials/pulses.py
def __getitem__(self, idx: int) -> Union[float, jnp.ndarray]:
    """
    Access pulse parameter(s) by index.

    For leaf gates, returns the parameter at the given index.
    For composite gates, returns parameters of the child at the given index.

    Args:
        idx (int): Index to access.

    Returns:
        Union[float, jnp.ndarray]: Parameter value or child parameters.
    """
    if self.is_leaf:
        return self.params[idx]
    else:
        return self.childs[idx].params

__init__(name='', params=None, decomposition=None) #

Parameters:

Name Type Description Default
name str

Gate name.

''
params Optional[ndarray]

Direct pulse parameters (leaf gates). Mutually exclusive with decomposition.

None
decomposition Optional[List[DecompositionStep]]

List of :class:DecompositionStep (composite gates). Mutually exclusive with params.

None
Source code in qml_essentials/pulses.py
def __init__(
    self,
    name: str = "",
    params: Optional[jnp.ndarray] = None,
    decomposition: Optional[List[DecompositionStep]] = None,
) -> None:
    """
    Args:
        name: Gate name.
        params: Direct pulse parameters (leaf gates).
            Mutually exclusive with *decomposition*.
        decomposition: List of :class:`DecompositionStep` (composite gates).
            Mutually exclusive with *params*.
    """
    assert (params is None) != (decomposition is None), (
        "Exactly one of `params` or `decomposition` must be provided."
    )

    self.decomposition = decomposition
    # Derive _pulse_obj for backward compat with childs/leafs/split_params
    self._pulse_obj = (
        [step.gate for step in decomposition] if decomposition else None
    )

    if params is not None:
        self._params = params

    self.name = name

__len__() #

Get the total number of pulse parameters.

For composite gates, returns the accumulated count from all children.

Returns:

Name Type Description
int int

Total number of pulse parameters.

Source code in qml_essentials/pulses.py
def __len__(self) -> int:
    """
    Get the total number of pulse parameters.

    For composite gates, returns the accumulated count from all children.

    Returns:
        int: Total number of pulse parameters.
    """
    return len(self.params)

__repr__() #

Return repr string (gate name).

Source code in qml_essentials/pulses.py
def __repr__(self) -> str:
    """Return repr string (gate name)."""
    return self.name

__str__() #

Return string representation (gate name).

Source code in qml_essentials/pulses.py
def __str__(self) -> str:
    """Return string representation (gate name)."""
    return self.name

split_params(params=None, leafs=False) #

Split parameters into sub-arrays for children or leaves.

Parameters:

Name Type Description Default
params Optional[ndarray]

Parameters to split. If None, uses internal parameters.

None
leafs bool

If True, splits across leaf nodes; if False, splits across direct children. Defaults to False.

False

Returns:

Type Description
List[ndarray]

List[jnp.ndarray]: List of parameter arrays for children or leaves.

Source code in qml_essentials/pulses.py
def split_params(
    self,
    params: Optional[jnp.ndarray] = None,
    leafs: bool = False,
) -> List[jnp.ndarray]:
    """
    Split parameters into sub-arrays for children or leaves.

    Args:
        params (Optional[jnp.ndarray]): Parameters to split. If None,
            uses internal parameters.
        leafs (bool): If True, splits across leaf nodes; if False,
            splits across direct children. Defaults to False.

    Returns:
        List[jnp.ndarray]: List of parameter arrays for children or leaves.
    """
    if params is None:
        if self.is_leaf:
            return self._params

        objs = self.leafs if leafs else self.childs
        s_params = []
        for obj in objs:
            s_params.append(obj.params)

        return s_params
    else:
        if self.is_leaf:
            return params

        objs = self.leafs if leafs else self.childs
        s_params = []
        idx = 0
        for obj in objs:
            nidx = idx + obj.size
            s_params.append(params[idx:nidx])
            idx = nidx

        return s_params

Pulse Envelope#

from qml_essentials.gates import PulseEnvelope

Registry of pulse envelope shapes.

Each envelope is a pure function (p, t, t_c) -> amplitude that computes the pulse envelope without carrier modulation. The carrier cos(omega_c * t + phi_c) is applied separately in the coefficient functions built by :meth:build_coeff_fns.

Attributes:

Name Type Description
REGISTRY

Mapping from envelope name to metadata dict containing fn (callable), n_envelope_params (int), and per-gate default parameter arrays.

Source code in qml_essentials/pulses.py
class PulseEnvelope:
    """Registry of pulse envelope shapes.

    Each envelope is a pure function ``(p, t, t_c) -> amplitude`` that
    computes the pulse envelope *without* carrier modulation.  The carrier
    ``cos(omega_c * t + phi_c)`` is applied separately in the coefficient
    functions built by :meth:`build_coeff_fns`.

    Attributes:
        REGISTRY: Mapping from envelope name to metadata dict containing
            ``fn`` (callable), ``n_envelope_params`` (int), and per-gate
            default parameter arrays.
    """

    @staticmethod
    def gaussian(p, t, t_c):
        """Gaussian envelope. ``p = [A, sigma]``."""
        A, sigma = p[0], p[1]
        return A * jnp.exp(-0.5 * ((t - t_c) / sigma) ** 2)

    @staticmethod
    def square(p, t, t_c):
        """Rectangular envelope. ``p = [A, width]``."""
        A, width = p[0], p[1]
        return A * (jnp.abs(t - t_c) <= width / 2)

    @staticmethod
    def cosine(p, t, t_c):
        """Raised cosine envelope. ``p = [A, width]``."""
        A, width = p[0], p[1]
        x = jnp.clip((t - t_c) / width, -0.5, 0.5)
        return A * jnp.cos(jnp.pi * x)

    @staticmethod
    def drag(p, t, t_c):
        """DRAG (Derivative Removal by Adiabatic Gate). ``p = [A, beta, sigma]``."""
        A, beta, sigma = p[0], p[1], p[2]
        g = A * jnp.exp(-0.5 * ((t - t_c) / sigma) ** 2)
        dg = g * (-(t - t_c) / sigma**2)
        return g + beta * dg

    @staticmethod
    def sech(p, t, t_c):
        """Hyperbolic secant envelope. ``p = [A, sigma]``."""
        A, sigma = p[0], p[1]
        return A / jnp.cosh((t - t_c) / sigma)

    # ``n_envelope_params`` counts only the envelope parameters (excluding
    # the evolution time ``t`` which is always the last element of the full
    # pulse parameter vector).
    REGISTRY = {
        "gaussian": {
            "fn": gaussian.__func__,
            "n_envelope_params": 2,
            "defaults": {
                "RX": jnp.array(
                    [0.38009941846766804, 1.631698142660167, 3.007403822238108]
                ),
                "RY": jnp.array(
                    [0.3836652338514791, 1.616595983505249, 2.9794135093698966]
                ),
            },
        },
        "square": {
            "fn": square.__func__,
            "n_envelope_params": 2,
            "defaults": {
                "RX": jnp.array(
                    [1.209655637514602, 0.8266815576721239, 1.1483122857413859]
                ),
                "RY": jnp.array(
                    [1.0287942142779052, 0.9860505130182093, 0.9720116870310977]
                ),
            },
        },
        "cosine": {
            "fn": cosine.__func__,
            "n_envelope_params": 2,
            "defaults": {
                "RX": jnp.array([1.0, 1.0, 1.0]),
                "RY": jnp.array([1.0, 1.0, 1.0]),
            },
        },
        "drag": {
            "fn": drag.__func__,
            "n_envelope_params": 3,
            "defaults": {
                "RX": jnp.array(
                    [
                        0.326562746114197,
                        0.4002767596709071,
                        5.3228107728890315,
                        3.141300761986467,
                    ]
                ),
                "RY": jnp.array(
                    [
                        0.323287924190616,
                        0.4065017233024265,
                        7.00299644871222,
                        3.139481229843545,
                    ]
                ),
            },
        },
        "sech": {
            "fn": sech.__func__,
            "n_envelope_params": 2,
            "defaults": {
                "RX": jnp.array([1.0, 1.0, 1.0]),
                "RY": jnp.array([1.0, 1.0, 1.0]),
            },
        },
        "general": {
            "fn": None,
            "n_envelope_params": 0,
            "defaults": {
                "RZ": jnp.array([0.5]),
                "CZ": jnp.array([0.3183098783513154]),
            },
        },
    }

    @staticmethod
    def available() -> List[str]:
        """Return list of registered envelope names."""
        return list(PulseEnvelope.REGISTRY.keys())

    @staticmethod
    def get(name: str) -> dict:
        """Look up envelope metadata by name.

        Raises:
            ValueError: If *name* is not registered.
        """
        if name not in PulseEnvelope.REGISTRY:
            raise ValueError(
                f"Unknown pulse envelope '{name}'. "
                f"Available: {PulseEnvelope.available()}"
            )
        return PulseEnvelope.REGISTRY[name]

    @staticmethod
    def build_coeff_fns(
        envelope_fn: Callable,
        omega_c: float,
        omega_q: float,
        rwa: bool = True,
        frame: str = "drive",
    ) -> Tuple[Callable, Callable, Callable, Callable]:
        """Build the four interaction-picture coefficient functions.

        The lab-frame Hamiltonian is

            H(t,Π) = H_static + Σ_j S_j(t;Π) H_j ,
            S_j(t;Π) = E_j(t;Π) · cos(ω_c·t + φ_c) ,

        and the interaction-picture transform with respect to
        ``H_static = (ω_q/2)·Z`` produces

            H̃_j(t) = exp(+i H_static t) H_j exp(-i H_static t) ,
            H_I(t) = Σ_j S_j(t) H̃_j(t) .

        For a single qubit driven on X, ``H̃_X(t) = cos(ω_q·t) X
        − sin(ω_q·t) Y``, so

            H_I(t) = Ω(t) · cos(ω_c·t + φ) ·
                     [ cos(ω_q·t) · X  −  sin(ω_q·t) · Y ] .

        ``rwa=True`` (default) drops the fast (~2·ω_q on resonance) terms and
        keeps only the slow envelope, yielding the analytical RWA

            H_I^RWA(t) = (Ω(t)/2) · [ cos(φ) X + sin(φ) Y ] .

        For RX (``φ = 0``) this reduces to ``(Ω/2)·X``; for RY
        (``φ = +π/2``) to ``(Ω/2)·Y``.  This is dramatically cheaper to
        integrate (no fast oscillations → adaptive ODE solver takes
        large steps).

        ``rwa=False``  keeps **both** the slow and the fast
        counter-rotating components.

        Each returned function has a unique ``__code__`` object so the
        jaqsi solver cache assigns separate compiled XLA programs per
        envelope shape and per (gate, component) pair.

        The rotation angle ``w`` is expected as the **last** element of
        the parameter array ``p`` (i.e. ``p[-1]``).  Envelope parameters
        occupy ``p[:-1]``.

        Args:
            envelope_fn: Pure envelope function ``(p, t, t_c) -> scalar``.
            omega_c: Carrier frequency.
            omega_q: Qubit frequency (interaction-picture rotation rate).
            rwa: When ``True``, return the RWA-truncated coefficients
                (no fast counter-rotating terms). Default ``True``
            frame: Algebraic representation of the exact (non-RWA)
                coefficients.  Mathematically equivalent options:

                * ``"drive"`` (default): applies the product-to-sum identity to
                  expose the slow ``(ω_c-ω_q)`` and fast ``(ω_c+ω_q)``
                  modes explicitly,
                  ``cos(ω_c t)cos(ω_q t) =
                  ½[cos((ω_c-ω_q)t) + cos((ω_c+ω_q)t)]``.  Algebraically
                  identical to ``"lab"`` (no RWA, no information lost).
                  Primary use: combined with the ``magnus2``/``magnus4``
                  jaqsi solvers, the explicit slow/fast decomposition
                  is sometimes numerically better-conditioned and lets
                  the user pick a fixed grid based on the slow
                  frequency alone (``Δ = |ω_c-ω_q|``) when the fast
                  ``(ω_c+ω_q)`` mode is well-resolved by the chosen
                  step.
                * ``"drive"``: the literal form
                  ``Ω(t) cos(ω_c t + φ) cos(ω_q t)`` (and the analogous
                  ``-sin`` term).  Two trig multiplications per call;
                  contains all four product frequencies implicitly.

                Ignored when ``rwa=True``.

        Returns:
            Tuple ``(coeff_RX_X, coeff_RX_Y, coeff_RY_X, coeff_RY_Y)``
            of coefficient functions for the X- and Y-components of the
            RX and RY interaction-picture Hamiltonians.
        """
        if frame not in ("lab", "drive"):
            raise ValueError(f"Unknown frame {frame!r}; expected 'lab' or 'drive'.")
        if rwa:
            # RWA-truncated coefficients (no carrier, no fast factors).
            # H_I^RWA = (Ω(t)/2) [cos(φ) X + sin(φ) Y]; we keep the
            # ``p[-1]`` rotation-angle scaling so the calling
            # ParametrizedHamiltonian shape is unchanged.
            half = jnp.asarray(0.5)

            def _coeff_RX_X(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                return half * env * p[-1]

            def _coeff_RX_Y(p, t):  # Y component vanishes for RX (φ=0)
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                return jnp.zeros_like(half * env * p[-1])

            def _coeff_RY_X(p, t):  # X component vanishes for RY (φ=π/2)
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                return jnp.zeros_like(half * env * p[-1])

            def _coeff_RY_Y(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                return half * env * p[-1]

            return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

        if frame == "drive":
            # Drive-frame: same exact dynamics, expressed via the
            # product-to-sum identities so the slow ``Δ = ω_c - ω_q``
            # and fast ``Σ = ω_c + ω_q`` modes appear explicitly.
            # Mathematically identical to the ``lab`` branch below.
            #
            # Identities used:
            #   cos(ω_c t) cos(ω_q t) = ½[cos(Δ t) + cos(Σ t)]
            #   cos(ω_c t) sin(ω_q t) = ½[sin(Σ t) − sin(Δ t)]
            #   −sin(ω_c t) cos(ω_q t) = −½[sin(Σ t) + sin(Δ t)]
            #   −sin(ω_c t) sin(ω_q t) = ½[cos(Σ t) − cos(Δ t)]
            # (RY uses cos(ω_c t + π/2) = −sin(ω_c t).)
            omega_d = omega_c - omega_q
            omega_s = omega_c + omega_q
            half = jnp.asarray(0.5)

            def _coeff_RX_X(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                mod = half * (jnp.cos(omega_d * t) + jnp.cos(omega_s * t))
                return env * mod * p[-1]

            def _coeff_RX_Y(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                mod = -half * (jnp.sin(omega_s * t) - jnp.sin(omega_d * t))
                return env * mod * p[-1]

            def _coeff_RY_X(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                mod = -half * (jnp.sin(omega_s * t) + jnp.sin(omega_d * t))
                return env * mod * p[-1]

            def _coeff_RY_Y(p, t):
                t_c = t / 2
                env = envelope_fn(p, t, t_c)
                mod = -half * (jnp.cos(omega_s * t) - jnp.cos(omega_d * t))
                return env * mod * p[-1]

            return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

        # RX uses carrier phase phi = 0 so that after RWA
        #   cos(ω_q τ)·cos(ω_q τ)  averages to +1/2  → drives +X
        #   -cos(ω_q τ)·sin(ω_q τ) averages to  0    → Y cancels
        # giving H_I^RWA ≈ (Ω/2)·X → U ≈ exp(-iθ/2 X), matching op.RX.
        # The exact form below KEEPS the fast 2·ω_q components.
        def _coeff_RX_X(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            carrier = jnp.cos(omega_c * t)
            return env * carrier * jnp.cos(omega_q * t) * p[-1]

        def _coeff_RX_Y(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            carrier = jnp.cos(omega_c * t)
            return -env * carrier * jnp.sin(omega_q * t) * p[-1]

        # RY uses carrier phase phi = +pi/2 so the RWA component drives +Y.
        def _coeff_RY_X(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            carrier = jnp.cos(omega_c * t + jnp.pi / 2)
            return env * carrier * jnp.cos(omega_q * t) * p[-1]

        def _coeff_RY_Y(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            carrier = jnp.cos(omega_c * t + jnp.pi / 2)
            return -env * carrier * jnp.sin(omega_q * t) * p[-1]

        return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

available() staticmethod #

Return list of registered envelope names.

Source code in qml_essentials/pulses.py
@staticmethod
def available() -> List[str]:
    """Return list of registered envelope names."""
    return list(PulseEnvelope.REGISTRY.keys())

build_coeff_fns(envelope_fn, omega_c, omega_q, rwa=True, frame='drive') staticmethod #

Build the four interaction-picture coefficient functions.

The lab-frame Hamiltonian is

H(t,Π) = H_static + Σ_j S_j(t;Π) H_j ,
S_j(t;Π) = E_j(t;Π) · cos(ω_c·t + φ_c) ,

and the interaction-picture transform with respect to H_static = (ω_q/2)·Z produces

H̃_j(t) = exp(+i H_static t) H_j exp(-i H_static t) ,
H_I(t) = Σ_j S_j(t) H̃_j(t) .

For a single qubit driven on X, H̃_X(t) = cos(ω_q·t) X − sin(ω_q·t) Y, so

H_I(t) = Ω(t) · cos(ω_c·t + φ) ·
         [ cos(ω_q·t) · X  −  sin(ω_q·t) · Y ] .

rwa=True (default) drops the fast (~2·ω_q on resonance) terms and keeps only the slow envelope, yielding the analytical RWA

H_I^RWA(t) = (Ω(t)/2) · [ cos(φ) X + sin(φ) Y ] .

For RX (φ = 0) this reduces to (Ω/2)·X; for RY (φ = +π/2) to (Ω/2)·Y. This is dramatically cheaper to integrate (no fast oscillations → adaptive ODE solver takes large steps).

rwa=False keeps both the slow and the fast counter-rotating components.

Each returned function has a unique __code__ object so the jaqsi solver cache assigns separate compiled XLA programs per envelope shape and per (gate, component) pair.

The rotation angle w is expected as the last element of the parameter array p (i.e. p[-1]). Envelope parameters occupy p[:-1].

Parameters:

Name Type Description Default
envelope_fn Callable

Pure envelope function (p, t, t_c) -> scalar.

required
omega_c float

Carrier frequency.

required
omega_q float

Qubit frequency (interaction-picture rotation rate).

required
rwa bool

When True, return the RWA-truncated coefficients (no fast counter-rotating terms). Default True

True
frame str

Algebraic representation of the exact (non-RWA) coefficients. Mathematically equivalent options:

  • "drive" (default): applies the product-to-sum identity to expose the slow (ω_c-ω_q) and fast (ω_c+ω_q) modes explicitly, cos(ω_c t)cos(ω_q t) = ½[cos((ω_c-ω_q)t) + cos((ω_c+ω_q)t)]. Algebraically identical to "lab" (no RWA, no information lost). Primary use: combined with the magnus2/magnus4 jaqsi solvers, the explicit slow/fast decomposition is sometimes numerically better-conditioned and lets the user pick a fixed grid based on the slow frequency alone (Δ = |ω_c-ω_q|) when the fast (ω_c+ω_q) mode is well-resolved by the chosen step.
  • "drive": the literal form Ω(t) cos(ω_c t + φ) cos(ω_q t) (and the analogous -sin term). Two trig multiplications per call; contains all four product frequencies implicitly.

Ignored when rwa=True.

'drive'

Returns:

Type Description
Callable

Tuple (coeff_RX_X, coeff_RX_Y, coeff_RY_X, coeff_RY_Y)

Callable

of coefficient functions for the X- and Y-components of the

Callable

RX and RY interaction-picture Hamiltonians.

Source code in qml_essentials/pulses.py
@staticmethod
def build_coeff_fns(
    envelope_fn: Callable,
    omega_c: float,
    omega_q: float,
    rwa: bool = True,
    frame: str = "drive",
) -> Tuple[Callable, Callable, Callable, Callable]:
    """Build the four interaction-picture coefficient functions.

    The lab-frame Hamiltonian is

        H(t,Π) = H_static + Σ_j S_j(t;Π) H_j ,
        S_j(t;Π) = E_j(t;Π) · cos(ω_c·t + φ_c) ,

    and the interaction-picture transform with respect to
    ``H_static = (ω_q/2)·Z`` produces

        H̃_j(t) = exp(+i H_static t) H_j exp(-i H_static t) ,
        H_I(t) = Σ_j S_j(t) H̃_j(t) .

    For a single qubit driven on X, ``H̃_X(t) = cos(ω_q·t) X
    − sin(ω_q·t) Y``, so

        H_I(t) = Ω(t) · cos(ω_c·t + φ) ·
                 [ cos(ω_q·t) · X  −  sin(ω_q·t) · Y ] .

    ``rwa=True`` (default) drops the fast (~2·ω_q on resonance) terms and
    keeps only the slow envelope, yielding the analytical RWA

        H_I^RWA(t) = (Ω(t)/2) · [ cos(φ) X + sin(φ) Y ] .

    For RX (``φ = 0``) this reduces to ``(Ω/2)·X``; for RY
    (``φ = +π/2``) to ``(Ω/2)·Y``.  This is dramatically cheaper to
    integrate (no fast oscillations → adaptive ODE solver takes
    large steps).

    ``rwa=False``  keeps **both** the slow and the fast
    counter-rotating components.

    Each returned function has a unique ``__code__`` object so the
    jaqsi solver cache assigns separate compiled XLA programs per
    envelope shape and per (gate, component) pair.

    The rotation angle ``w`` is expected as the **last** element of
    the parameter array ``p`` (i.e. ``p[-1]``).  Envelope parameters
    occupy ``p[:-1]``.

    Args:
        envelope_fn: Pure envelope function ``(p, t, t_c) -> scalar``.
        omega_c: Carrier frequency.
        omega_q: Qubit frequency (interaction-picture rotation rate).
        rwa: When ``True``, return the RWA-truncated coefficients
            (no fast counter-rotating terms). Default ``True``
        frame: Algebraic representation of the exact (non-RWA)
            coefficients.  Mathematically equivalent options:

            * ``"drive"`` (default): applies the product-to-sum identity to
              expose the slow ``(ω_c-ω_q)`` and fast ``(ω_c+ω_q)``
              modes explicitly,
              ``cos(ω_c t)cos(ω_q t) =
              ½[cos((ω_c-ω_q)t) + cos((ω_c+ω_q)t)]``.  Algebraically
              identical to ``"lab"`` (no RWA, no information lost).
              Primary use: combined with the ``magnus2``/``magnus4``
              jaqsi solvers, the explicit slow/fast decomposition
              is sometimes numerically better-conditioned and lets
              the user pick a fixed grid based on the slow
              frequency alone (``Δ = |ω_c-ω_q|``) when the fast
              ``(ω_c+ω_q)`` mode is well-resolved by the chosen
              step.
            * ``"drive"``: the literal form
              ``Ω(t) cos(ω_c t + φ) cos(ω_q t)`` (and the analogous
              ``-sin`` term).  Two trig multiplications per call;
              contains all four product frequencies implicitly.

            Ignored when ``rwa=True``.

    Returns:
        Tuple ``(coeff_RX_X, coeff_RX_Y, coeff_RY_X, coeff_RY_Y)``
        of coefficient functions for the X- and Y-components of the
        RX and RY interaction-picture Hamiltonians.
    """
    if frame not in ("lab", "drive"):
        raise ValueError(f"Unknown frame {frame!r}; expected 'lab' or 'drive'.")
    if rwa:
        # RWA-truncated coefficients (no carrier, no fast factors).
        # H_I^RWA = (Ω(t)/2) [cos(φ) X + sin(φ) Y]; we keep the
        # ``p[-1]`` rotation-angle scaling so the calling
        # ParametrizedHamiltonian shape is unchanged.
        half = jnp.asarray(0.5)

        def _coeff_RX_X(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            return half * env * p[-1]

        def _coeff_RX_Y(p, t):  # Y component vanishes for RX (φ=0)
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            return jnp.zeros_like(half * env * p[-1])

        def _coeff_RY_X(p, t):  # X component vanishes for RY (φ=π/2)
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            return jnp.zeros_like(half * env * p[-1])

        def _coeff_RY_Y(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            return half * env * p[-1]

        return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

    if frame == "drive":
        # Drive-frame: same exact dynamics, expressed via the
        # product-to-sum identities so the slow ``Δ = ω_c - ω_q``
        # and fast ``Σ = ω_c + ω_q`` modes appear explicitly.
        # Mathematically identical to the ``lab`` branch below.
        #
        # Identities used:
        #   cos(ω_c t) cos(ω_q t) = ½[cos(Δ t) + cos(Σ t)]
        #   cos(ω_c t) sin(ω_q t) = ½[sin(Σ t) − sin(Δ t)]
        #   −sin(ω_c t) cos(ω_q t) = −½[sin(Σ t) + sin(Δ t)]
        #   −sin(ω_c t) sin(ω_q t) = ½[cos(Σ t) − cos(Δ t)]
        # (RY uses cos(ω_c t + π/2) = −sin(ω_c t).)
        omega_d = omega_c - omega_q
        omega_s = omega_c + omega_q
        half = jnp.asarray(0.5)

        def _coeff_RX_X(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            mod = half * (jnp.cos(omega_d * t) + jnp.cos(omega_s * t))
            return env * mod * p[-1]

        def _coeff_RX_Y(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            mod = -half * (jnp.sin(omega_s * t) - jnp.sin(omega_d * t))
            return env * mod * p[-1]

        def _coeff_RY_X(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            mod = -half * (jnp.sin(omega_s * t) + jnp.sin(omega_d * t))
            return env * mod * p[-1]

        def _coeff_RY_Y(p, t):
            t_c = t / 2
            env = envelope_fn(p, t, t_c)
            mod = -half * (jnp.cos(omega_s * t) - jnp.cos(omega_d * t))
            return env * mod * p[-1]

        return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

    # RX uses carrier phase phi = 0 so that after RWA
    #   cos(ω_q τ)·cos(ω_q τ)  averages to +1/2  → drives +X
    #   -cos(ω_q τ)·sin(ω_q τ) averages to  0    → Y cancels
    # giving H_I^RWA ≈ (Ω/2)·X → U ≈ exp(-iθ/2 X), matching op.RX.
    # The exact form below KEEPS the fast 2·ω_q components.
    def _coeff_RX_X(p, t):
        t_c = t / 2
        env = envelope_fn(p, t, t_c)
        carrier = jnp.cos(omega_c * t)
        return env * carrier * jnp.cos(omega_q * t) * p[-1]

    def _coeff_RX_Y(p, t):
        t_c = t / 2
        env = envelope_fn(p, t, t_c)
        carrier = jnp.cos(omega_c * t)
        return -env * carrier * jnp.sin(omega_q * t) * p[-1]

    # RY uses carrier phase phi = +pi/2 so the RWA component drives +Y.
    def _coeff_RY_X(p, t):
        t_c = t / 2
        env = envelope_fn(p, t, t_c)
        carrier = jnp.cos(omega_c * t + jnp.pi / 2)
        return env * carrier * jnp.cos(omega_q * t) * p[-1]

    def _coeff_RY_Y(p, t):
        t_c = t / 2
        env = envelope_fn(p, t, t_c)
        carrier = jnp.cos(omega_c * t + jnp.pi / 2)
        return -env * carrier * jnp.sin(omega_q * t) * p[-1]

    return _coeff_RX_X, _coeff_RX_Y, _coeff_RY_X, _coeff_RY_Y

cosine(p, t, t_c) staticmethod #

Raised cosine envelope. p = [A, width].

Source code in qml_essentials/pulses.py
@staticmethod
def cosine(p, t, t_c):
    """Raised cosine envelope. ``p = [A, width]``."""
    A, width = p[0], p[1]
    x = jnp.clip((t - t_c) / width, -0.5, 0.5)
    return A * jnp.cos(jnp.pi * x)

drag(p, t, t_c) staticmethod #

DRAG (Derivative Removal by Adiabatic Gate). p = [A, beta, sigma].

Source code in qml_essentials/pulses.py
@staticmethod
def drag(p, t, t_c):
    """DRAG (Derivative Removal by Adiabatic Gate). ``p = [A, beta, sigma]``."""
    A, beta, sigma = p[0], p[1], p[2]
    g = A * jnp.exp(-0.5 * ((t - t_c) / sigma) ** 2)
    dg = g * (-(t - t_c) / sigma**2)
    return g + beta * dg

gaussian(p, t, t_c) staticmethod #

Gaussian envelope. p = [A, sigma].

Source code in qml_essentials/pulses.py
@staticmethod
def gaussian(p, t, t_c):
    """Gaussian envelope. ``p = [A, sigma]``."""
    A, sigma = p[0], p[1]
    return A * jnp.exp(-0.5 * ((t - t_c) / sigma) ** 2)

get(name) staticmethod #

Look up envelope metadata by name.

Raises:

Type Description
ValueError

If name is not registered.

Source code in qml_essentials/pulses.py
@staticmethod
def get(name: str) -> dict:
    """Look up envelope metadata by name.

    Raises:
        ValueError: If *name* is not registered.
    """
    if name not in PulseEnvelope.REGISTRY:
        raise ValueError(
            f"Unknown pulse envelope '{name}'. "
            f"Available: {PulseEnvelope.available()}"
        )
    return PulseEnvelope.REGISTRY[name]

sech(p, t, t_c) staticmethod #

Hyperbolic secant envelope. p = [A, sigma].

Source code in qml_essentials/pulses.py
@staticmethod
def sech(p, t, t_c):
    """Hyperbolic secant envelope. ``p = [A, sigma]``."""
    A, sigma = p[0], p[1]
    return A / jnp.cosh((t - t_c) / sigma)

square(p, t, t_c) staticmethod #

Rectangular envelope. p = [A, width].

Source code in qml_essentials/pulses.py
@staticmethod
def square(p, t, t_c):
    """Rectangular envelope. ``p = [A, width]``."""
    A, width = p[0], p[1]
    return A * (jnp.abs(t - t_c) <= width / 2)

Pulse Information#

from qml_essentials.gates import PulseInformation

Stores pulse parameter counts and optimized pulse parameters.

Call :meth:set_envelope to switch the active pulse shape. This rebuilds all :class:PulseParams trees so that parameter counts and defaults match the selected envelope.

Source code in qml_essentials/pulses.py
class PulseInformation:
    """Stores pulse parameter counts and optimized pulse parameters.

    Call :meth:`set_envelope` to switch the active pulse shape.  This
    rebuilds all :class:`PulseParams` trees so that parameter counts
    and defaults match the selected envelope.
    """

    DEFAULT_ENVELOPE: str = "drag"
    DEFAULT_RWA: bool = True
    DEFAULT_FRAME: str = "drive"
    LEAF_GATE_NAMES: Tuple[str, ...] = ("RX", "RY", "RZ", "CZ")

    _envelope: str = DEFAULT_ENVELOPE
    # Whether to apply the rotating-wave approximation when building the
    # interaction-picture coefficient functions.
    # Default ``True`` (exact dynamics, no RWA).
    # Setting to ``True`` drops the fast counter-rotating terms —
    # much faster to integrate
    # See :meth:`PulseEnvelope.build_coeff_fns`.
    _rwa: bool = DEFAULT_RWA
    # Algebraic representation of the (non-RWA) coefficients.  Either
    # ``"lab"`` or ``"drive"`` (product-to-sum decomposition).
    # Mathematically equivalent — see :meth:`PulseEnvelope.build_coeff_fns`
    # when ``"drive"`` is numerically advantageous (mainly with the Magnus solvers).
    _frame: str = DEFAULT_FRAME

    @classmethod
    def _build_leaf_gates(cls):
        """(Re-)create leaf PulseParams from the active envelope defaults."""
        defaults = PulseEnvelope.get(cls._envelope)["defaults"]
        general = PulseEnvelope.get("general")["defaults"]

        cls.RX = PulseParams(name="RX", params=defaults["RX"])
        cls.RY = PulseParams(name="RY", params=defaults["RY"])

        cls.RZ = PulseParams(name="RZ", params=general["RZ"])
        cls.CZ = PulseParams(name="CZ", params=general["CZ"])

    @classmethod
    def _build_composite_gates(cls):
        """(Re-)create composite PulseParams trees from current leaves."""
        cls.H = PulseParams(
            name="H",
            decomposition=[
                DecompositionStep(cls.RZ, "all", lambda w: jnp.pi),
                DecompositionStep(cls.RY, "all", lambda w: jnp.pi / 2),
            ],
        )
        cls.CX = PulseParams(
            name="CX",
            decomposition=[
                DecompositionStep(cls.H, "target", lambda w: 0.0),
                DecompositionStep(cls.CZ, "all", lambda w: 0.0),
                DecompositionStep(cls.H, "target", lambda w: 0.0),
            ],
        )
        cls.CY = PulseParams(
            name="CY",
            decomposition=[
                DecompositionStep(cls.RZ, "target", lambda w: -jnp.pi / 2),
                DecompositionStep(cls.CX, "all"),
                DecompositionStep(cls.RZ, "target", lambda w: jnp.pi / 2),
            ],
        )
        cls.CRX = PulseParams(
            name="CRX",
            decomposition=[
                DecompositionStep(cls.RZ, "target", lambda w: jnp.pi / 2),
                DecompositionStep(cls.RY, "target", lambda w: w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RY, "target", lambda w: -w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: -jnp.pi / 2),
            ],
        )
        cls.CRY = PulseParams(
            name="CRY",
            decomposition=[
                DecompositionStep(cls.RY, "target", lambda w: w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RY, "target", lambda w: -w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
            ],
        )
        cls.CRZ = PulseParams(
            name="CRZ",
            decomposition=[
                DecompositionStep(cls.RZ, "target", lambda w: w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: -w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
            ],
        )
        # TODO: check if we could just make this a basis gate instead
        cls.CPhase = PulseParams(
            name="CPhase",
            decomposition=[
                DecompositionStep(cls.RZ, "control", lambda w: w / 2),
                DecompositionStep(cls.RZ, "target", lambda w: w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: -w / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
            ],
        )
        cls.RZZ = PulseParams(
            name="RZZ",
            decomposition=[
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: w),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
            ],
        )
        cls.RXX = PulseParams(
            name="RXX",
            decomposition=[
                DecompositionStep(cls.H, "control", lambda w: 0.0),
                DecompositionStep(cls.H, "target", lambda w: 0.0),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: w),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.H, "control", lambda w: 0.0),
                DecompositionStep(cls.H, "target", lambda w: 0.0),
            ],
        )
        cls.RYY = PulseParams(
            name="RYY",
            decomposition=[
                DecompositionStep(cls.RX, "control", lambda w: jnp.pi / 2),
                DecompositionStep(cls.RX, "target", lambda w: jnp.pi / 2),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: w),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RX, "control", lambda w: -jnp.pi / 2),
                DecompositionStep(cls.RX, "target", lambda w: -jnp.pi / 2),
            ],
        )
        cls.RZX = PulseParams(
            name="RZX",
            decomposition=[
                DecompositionStep(cls.H, "target", lambda w: 0.0),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.RZ, "target", lambda w: w),
                DecompositionStep(cls.CX, "all", lambda w: 0.0),
                DecompositionStep(cls.H, "target", lambda w: 0.0),
            ],
        )
        cls.Rot = PulseParams(
            name="Rot",
            decomposition=[
                DecompositionStep(cls.RZ, "all", lambda w: w[0]),
                DecompositionStep(cls.RY, "all", lambda w: w[1]),
                DecompositionStep(cls.RZ, "all", lambda w: w[2]),
            ],
        )
        cls.unique_gate_set = [cls.RX, cls.RY, cls.RZ, cls.CZ]

    @classmethod
    def set_envelope(
        cls,
        name: str,
        rwa: Optional[bool] = None,
        frame: Optional[str] = None,
    ) -> None:
        """Switch pulse envelope and rebuild all PulseParams trees.

        Also updates the coefficient functions used by :class:`PulseGates`.

        Args:
            name: One of :meth:`PulseEnvelope.available`.
            rwa: If given, also update the RWA flag.  If ``None`` (the
                default), the current value of ``cls._rwa`` is kept.
                See :meth:`PulseEnvelope.build_coeff_fns` for the
                physical meaning of the flag.
            frame: If given, also update the coefficient frame
                (``"lab"`` or ``"drive"``).  ``None`` keeps the current
                value of ``cls._frame``.  Ignored when ``rwa=True`` or
                when the existing RWA flag is on.
        """
        info = PulseEnvelope.get(name)  # validates name
        cls._envelope = name
        if rwa is not None:
            cls._rwa = bool(rwa)
        if frame is not None:
            if frame not in ("lab", "drive"):
                raise ValueError(f"Unknown frame {frame!r}; expected 'lab' or 'drive'.")
            cls._frame = frame
        cls._build_leaf_gates()
        cls._build_composite_gates()

        # Rebuild interaction-picture coefficient functions on PulseGates.
        # Four functions: (RX_X, RX_Y, RY_X, RY_Y) — one per (gate, Pauli)
        # component of the proper interaction-picture drive Hamiltonian.
        rx_x, rx_y, ry_x, ry_y = PulseEnvelope.build_coeff_fns(
            info["fn"],
            PulseGates.omega_c,
            PulseGates.omega_q,
            rwa=cls._rwa,
            frame=cls._frame,
        )
        PulseGates._coeff_RX_X = staticmethod(rx_x)
        PulseGates._coeff_RX_Y = staticmethod(rx_y)
        PulseGates._coeff_RY_X = staticmethod(ry_x)
        PulseGates._coeff_RY_Y = staticmethod(ry_y)
        # Backward-compat aliases for older introspection (point at the
        # X-component which dominates RX, Y-component which dominates RY).
        PulseGates._coeff_Sx = staticmethod(rx_x)
        PulseGates._coeff_Sy = staticmethod(ry_y)
        PulseGates._active_envelope = name
        PulseGates._active_rwa = cls._rwa
        PulseGates._active_frame = cls._frame

        # The compiled-solver cache in ``Evolution`` is keyed on the code
        # objects of the coefficient functions.  Rebuilding the coeff
        # fns above produced fresh code objects, so any cached solver
        # is now unreachable from the live coefficient functions and
        # must be evicted to avoid both (a) holding compiled programs
        # for a previous configuration alive forever and (b) returning
        # a stale program if ``id`` collisions ever leaked through.
        js.Evolution.clear_evolve_solver_cache()

        log.info(
            f"Pulse envelope set to '{name}' "
            f"(RWA {'on' if cls._rwa else 'off'}, frame={cls._frame})"
        )

    @classmethod
    def set_rwa(cls, rwa: bool) -> None:
        """Toggle the rotating-wave approximation for pulse coefficients.

        Rebuilds the coefficient functions for the currently active
        envelope so the change takes effect immediately.  Default is
        ``False`` (exact interaction picture).
        See :meth:`PulseEnvelope.build_coeff_fns` for details
        """
        cls.set_envelope(cls._envelope, rwa=bool(rwa))

    @classmethod
    def get_envelope(cls) -> str:
        """Return the name of the active pulse envelope."""
        return cls._envelope

    @classmethod
    def get_rwa(cls) -> bool:
        """Return whether the RWA flag is currently active."""
        return cls._rwa

    @classmethod
    def set_frame(cls, frame: str) -> None:
        """Switch the algebraic representation of the (non-RWA) coefficients.

        ``"lab"`` (default) and ``"drive"`` are mathematically
        identical (no information lost, no RWA applied) — see
        :meth:`PulseEnvelope.build_coeff_fns` for when ``"drive"`` is
        useful.  Rebuilds the coefficient functions for the currently
        active envelope so the change takes effect immediately.
        """
        cls.set_envelope(cls._envelope, frame=str(frame))

    @classmethod
    def get_frame(cls) -> str:
        """Return the active coefficient frame (``"lab"`` or ``"drive"``)."""
        return cls._frame

    @classmethod
    def snapshot_state(cls) -> PulseStateSnapshot:
        """Return an immutable snapshot of the active pulse configuration."""
        leaf_params = {}
        for name in cls.LEAF_GATE_NAMES:
            gate = getattr(cls, name, None)
            if gate is not None:
                leaf_params[name] = jnp.array(gate.params)

        return PulseStateSnapshot(
            envelope=cls._envelope,
            rwa=cls._rwa,
            frame=cls._frame,
            leaf_params=leaf_params,
        )

    @classmethod
    def restore_state(cls, snapshot: PulseStateSnapshot) -> None:
        """Restore a snapshot produced by :meth:`snapshot_state`."""
        cls.set_envelope(snapshot.envelope, rwa=snapshot.rwa, frame=snapshot.frame)

        for name, params in snapshot.leaf_params.items():
            gate = cls.gate_by_name(name)
            if gate is None or not gate.is_leaf:
                raise ValueError(f"Cannot restore unknown leaf pulse gate {name!r}.")
            if gate.params.shape != params.shape:
                raise ValueError(
                    f"Snapshot for {name!r} has shape {params.shape}, "
                    f"but active gate expects {gate.params.shape}."
                )
            gate.params = params

    @classmethod
    @contextmanager
    def preserve_state(cls):
        """Temporarily preserve global pulse state across scoped mutations."""
        snapshot = cls.snapshot_state()
        try:
            yield snapshot
        finally:
            cls.restore_state(snapshot)

    @classmethod
    def reset_defaults(
        cls,
        envelope: Optional[str] = None,
        rwa: Optional[bool] = None,
        frame: Optional[str] = None,
    ) -> None:
        """Reset pulse globals to canonical defaults or explicit values."""
        cls.set_envelope(
            cls.DEFAULT_ENVELOPE if envelope is None else envelope,
            rwa=cls.DEFAULT_RWA if rwa is None else rwa,
            frame=cls.DEFAULT_FRAME if frame is None else frame,
        )

    @staticmethod
    def gate_by_name(gate):
        if isinstance(gate, str):
            return getattr(PulseInformation, gate, None)
        else:
            return getattr(PulseInformation, gate.__name__, None)

    @staticmethod
    def num_params(gate):
        return len(PulseInformation.gate_by_name(gate))

    @staticmethod
    def update_params(path=f"{os.getcwd()}/qml_essentials/qoc_results.csv"):
        if os.path.isfile(path):
            log.info(f"Loading optimized pulses from {path}")
            with open(path, "r") as f:
                reader = csv.reader(f)

                for row in reader:
                    log.debug(
                        f"Loading optimized pulses for {row[0]}\
                            (Fidelity: {float(row[1]):.5f}): {row[2:]}"
                    )
                    PulseInformation.OPTIMIZED_PULSES[row[0]] = jnp.array(
                        [float(x) for x in row[2:]]
                    )
        else:
            log.error(f"No optimized pulses found at {path}")

    @staticmethod
    def shuffle_params(random_key):
        log.info(
            f"Shuffling optimized pulses with random key {random_key}\
              of gates {PulseInformation.unique_gate_set}"
        )
        for gate in PulseInformation.unique_gate_set:
            random_key, sub_key = safe_random_split(random_key)
            gate.params = jax.random.uniform(sub_key, (len(gate),))

get_envelope() classmethod #

Return the name of the active pulse envelope.

Source code in qml_essentials/pulses.py
@classmethod
def get_envelope(cls) -> str:
    """Return the name of the active pulse envelope."""
    return cls._envelope

get_frame() classmethod #

Return the active coefficient frame ("lab" or "drive").

Source code in qml_essentials/pulses.py
@classmethod
def get_frame(cls) -> str:
    """Return the active coefficient frame (``"lab"`` or ``"drive"``)."""
    return cls._frame

get_rwa() classmethod #

Return whether the RWA flag is currently active.

Source code in qml_essentials/pulses.py
@classmethod
def get_rwa(cls) -> bool:
    """Return whether the RWA flag is currently active."""
    return cls._rwa

preserve_state() classmethod #

Temporarily preserve global pulse state across scoped mutations.

Source code in qml_essentials/pulses.py
@classmethod
@contextmanager
def preserve_state(cls):
    """Temporarily preserve global pulse state across scoped mutations."""
    snapshot = cls.snapshot_state()
    try:
        yield snapshot
    finally:
        cls.restore_state(snapshot)

reset_defaults(envelope=None, rwa=None, frame=None) classmethod #

Reset pulse globals to canonical defaults or explicit values.

Source code in qml_essentials/pulses.py
@classmethod
def reset_defaults(
    cls,
    envelope: Optional[str] = None,
    rwa: Optional[bool] = None,
    frame: Optional[str] = None,
) -> None:
    """Reset pulse globals to canonical defaults or explicit values."""
    cls.set_envelope(
        cls.DEFAULT_ENVELOPE if envelope is None else envelope,
        rwa=cls.DEFAULT_RWA if rwa is None else rwa,
        frame=cls.DEFAULT_FRAME if frame is None else frame,
    )

restore_state(snapshot) classmethod #

Restore a snapshot produced by :meth:snapshot_state.

Source code in qml_essentials/pulses.py
@classmethod
def restore_state(cls, snapshot: PulseStateSnapshot) -> None:
    """Restore a snapshot produced by :meth:`snapshot_state`."""
    cls.set_envelope(snapshot.envelope, rwa=snapshot.rwa, frame=snapshot.frame)

    for name, params in snapshot.leaf_params.items():
        gate = cls.gate_by_name(name)
        if gate is None or not gate.is_leaf:
            raise ValueError(f"Cannot restore unknown leaf pulse gate {name!r}.")
        if gate.params.shape != params.shape:
            raise ValueError(
                f"Snapshot for {name!r} has shape {params.shape}, "
                f"but active gate expects {gate.params.shape}."
            )
        gate.params = params

set_envelope(name, rwa=None, frame=None) classmethod #

Switch pulse envelope and rebuild all PulseParams trees.

Also updates the coefficient functions used by :class:PulseGates.

Parameters:

Name Type Description Default
name str

One of :meth:PulseEnvelope.available.

required
rwa Optional[bool]

If given, also update the RWA flag. If None (the default), the current value of cls._rwa is kept. See :meth:PulseEnvelope.build_coeff_fns for the physical meaning of the flag.

None
frame Optional[str]

If given, also update the coefficient frame ("lab" or "drive"). None keeps the current value of cls._frame. Ignored when rwa=True or when the existing RWA flag is on.

None
Source code in qml_essentials/pulses.py
@classmethod
def set_envelope(
    cls,
    name: str,
    rwa: Optional[bool] = None,
    frame: Optional[str] = None,
) -> None:
    """Switch pulse envelope and rebuild all PulseParams trees.

    Also updates the coefficient functions used by :class:`PulseGates`.

    Args:
        name: One of :meth:`PulseEnvelope.available`.
        rwa: If given, also update the RWA flag.  If ``None`` (the
            default), the current value of ``cls._rwa`` is kept.
            See :meth:`PulseEnvelope.build_coeff_fns` for the
            physical meaning of the flag.
        frame: If given, also update the coefficient frame
            (``"lab"`` or ``"drive"``).  ``None`` keeps the current
            value of ``cls._frame``.  Ignored when ``rwa=True`` or
            when the existing RWA flag is on.
    """
    info = PulseEnvelope.get(name)  # validates name
    cls._envelope = name
    if rwa is not None:
        cls._rwa = bool(rwa)
    if frame is not None:
        if frame not in ("lab", "drive"):
            raise ValueError(f"Unknown frame {frame!r}; expected 'lab' or 'drive'.")
        cls._frame = frame
    cls._build_leaf_gates()
    cls._build_composite_gates()

    # Rebuild interaction-picture coefficient functions on PulseGates.
    # Four functions: (RX_X, RX_Y, RY_X, RY_Y) — one per (gate, Pauli)
    # component of the proper interaction-picture drive Hamiltonian.
    rx_x, rx_y, ry_x, ry_y = PulseEnvelope.build_coeff_fns(
        info["fn"],
        PulseGates.omega_c,
        PulseGates.omega_q,
        rwa=cls._rwa,
        frame=cls._frame,
    )
    PulseGates._coeff_RX_X = staticmethod(rx_x)
    PulseGates._coeff_RX_Y = staticmethod(rx_y)
    PulseGates._coeff_RY_X = staticmethod(ry_x)
    PulseGates._coeff_RY_Y = staticmethod(ry_y)
    # Backward-compat aliases for older introspection (point at the
    # X-component which dominates RX, Y-component which dominates RY).
    PulseGates._coeff_Sx = staticmethod(rx_x)
    PulseGates._coeff_Sy = staticmethod(ry_y)
    PulseGates._active_envelope = name
    PulseGates._active_rwa = cls._rwa
    PulseGates._active_frame = cls._frame

    # The compiled-solver cache in ``Evolution`` is keyed on the code
    # objects of the coefficient functions.  Rebuilding the coeff
    # fns above produced fresh code objects, so any cached solver
    # is now unreachable from the live coefficient functions and
    # must be evicted to avoid both (a) holding compiled programs
    # for a previous configuration alive forever and (b) returning
    # a stale program if ``id`` collisions ever leaked through.
    js.Evolution.clear_evolve_solver_cache()

    log.info(
        f"Pulse envelope set to '{name}' "
        f"(RWA {'on' if cls._rwa else 'off'}, frame={cls._frame})"
    )

set_frame(frame) classmethod #

Switch the algebraic representation of the (non-RWA) coefficients.

"lab" (default) and "drive" are mathematically identical (no information lost, no RWA applied) — see :meth:PulseEnvelope.build_coeff_fns for when "drive" is useful. Rebuilds the coefficient functions for the currently active envelope so the change takes effect immediately.

Source code in qml_essentials/pulses.py
@classmethod
def set_frame(cls, frame: str) -> None:
    """Switch the algebraic representation of the (non-RWA) coefficients.

    ``"lab"`` (default) and ``"drive"`` are mathematically
    identical (no information lost, no RWA applied) — see
    :meth:`PulseEnvelope.build_coeff_fns` for when ``"drive"`` is
    useful.  Rebuilds the coefficient functions for the currently
    active envelope so the change takes effect immediately.
    """
    cls.set_envelope(cls._envelope, frame=str(frame))

set_rwa(rwa) classmethod #

Toggle the rotating-wave approximation for pulse coefficients.

Rebuilds the coefficient functions for the currently active envelope so the change takes effect immediately. Default is False (exact interaction picture). See :meth:PulseEnvelope.build_coeff_fns for details

Source code in qml_essentials/pulses.py
@classmethod
def set_rwa(cls, rwa: bool) -> None:
    """Toggle the rotating-wave approximation for pulse coefficients.

    Rebuilds the coefficient functions for the currently active
    envelope so the change takes effect immediately.  Default is
    ``False`` (exact interaction picture).
    See :meth:`PulseEnvelope.build_coeff_fns` for details
    """
    cls.set_envelope(cls._envelope, rwa=bool(rwa))

snapshot_state() classmethod #

Return an immutable snapshot of the active pulse configuration.

Source code in qml_essentials/pulses.py
@classmethod
def snapshot_state(cls) -> PulseStateSnapshot:
    """Return an immutable snapshot of the active pulse configuration."""
    leaf_params = {}
    for name in cls.LEAF_GATE_NAMES:
        gate = getattr(cls, name, None)
        if gate is not None:
            leaf_params[name] = jnp.array(gate.params)

    return PulseStateSnapshot(
        envelope=cls._envelope,
        rwa=cls._rwa,
        frame=cls._frame,
        leaf_params=leaf_params,
    )

Model#

from qml_essentials.model import Model

A quantum circuit model.

Source code in qml_essentials/model.py
  21
  22
  23
  24
  25
  26
  27
  28
  29
  30
  31
  32
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
class Model:
    """
    A quantum circuit model.
    """

    def __init__(
        self,
        n_qubits: int,
        n_layers: int,
        circuit_type: Union[str, Circuit] = "No_Ansatz",
        data_reupload: Union[bool, List[List[bool]], List[List[List[bool]]]] = True,
        state_preparation: Union[
            str, Callable, List[Union[str, Callable]], None
        ] = None,
        encoding: Union[Encoding, str, Callable, List[Union[str, Callable]]] = Gates.RX,
        trainable_frequencies: bool = False,
        initialization: str = "random",
        initialization_domain: List[float] = [0, 2 * jnp.pi],
        output_qubit: Union[List[int], int] = -1,
        shots: Optional[int] = None,
        random_seed: int = 1000,
        remove_zero_encoding: bool = True,
        repeat_batch_axis: List[bool] = [True, True, True],
        pulse_shape: str = "gaussian",
    ) -> None:
        """
        Initialize the quantum circuit model.
        Parameters will have the shape [impl_n_layers, parameters_per_layer]
        where impl_n_layers is the number of layers provided and added by one
        depending if data_reupload is True and parameters_per_layer is given by
        the chosen ansatz.

        The model is initialized with the following parameters as defaults:
        - noise_params: None
        - execution_type: "expval"
        - shots: None

        Args:
            n_qubits (int): The number of qubits in the circuit.
            n_layers (int): The number of layers in the circuit.
            circuit_type (str, Circuit): The type of quantum circuit to use.
                If None, defaults to "no_ansatz".
            data_reupload (Union[bool, List[bool], List[List[bool]]], optional):
                Whether to reupload data to the quantum device on each
                layer and qubit. Detailed re-uploading instructions can be given
                as a list/array of 0/False and 1/True with shape (n_qubits,
                n_layers) to specify where to upload the data. Defaults to True
                for applying data re-uploading to the full circuit.
            encoding (Union[str, Callable, List[str], List[Callable]], optional):
                The unitary to use for encoding the input data. Can be a string
                (e.g. "RX") or a callable (e.g. op.RX). Defaults to op.RX.
                If input is multidimensional it is assumed to be a list of
                unitaries or a list of strings.
            trainable_frequencies (bool, optional):
                Sets trainable encoding parameters for trainable frequencies.
                Defaults to False.
            initialization (str, optional): The strategy to initialize the parameters.
                Can be "random", "zeros", "zero-controlled", "pi", or "pi-controlled".
                Defaults to "random".
            output_qubit (List[int], int, optional): The index of the output
                qubit (or qubits). When set to -1 all qubits are measured, or a
                global measurement is conducted, depending on the execution
                type.
            shots (Optional[int], optional): The number of shots to use for
                the quantum device. Defaults to None.
            random_seed (int, optional): seed for the random number generator
                in initialization is "random" and for random noise parameters.
                Defaults to 1000.
            remove_zero_encoding (bool, optional): whether to
                remove the zero encoding from the circuit. Defaults to True.
            repeat_batch_axis (List[bool], optional): Each boolean in the array
                determines over which axes to parallelise computation. The axes
                correspond to [inputs, params, pulse_params]. Defaults to
                [True, True, True], meaning that batching is enabled over all
                axes.
            pulse_shape (str, optional): Pulse envelope shape for pulse-level
                simulation. One of ``PulseEnvelope.available()``.
                Defaults to ``"gaussian"``.

        Returns:
            None
        """
        # Initialize default parameters needed for circuit evaluation
        self.n_qubits: int = n_qubits
        self.output_qubit: Union[List[int], int] = output_qubit
        self.n_layers: int = n_layers
        self.noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None
        self.shots = shots
        self.remove_zero_encoding = remove_zero_encoding
        self.trainable_frequencies: bool = trainable_frequencies
        self.execution_type: str = "expval"
        self.repeat_batch_axis: List[bool] = repeat_batch_axis

        # --- Pulse envelope ---
        pinfo.set_envelope(pulse_shape)

        # --- State Preparation ---
        try:
            self._sp = Gates.parse_gates(state_preparation, Gates)
        except ValueError as e:
            raise ValueError(f"Error parsing encodings: {e}")

        # prepare corresponding pulse parameters (always optimized pulses)
        self.sp_pulse_params = []
        for sp in self._sp:
            sp_name = sp.__name__ if hasattr(sp, "__name__") else str(sp)

            if pinfo.gate_by_name(sp_name) is not None:
                self.sp_pulse_params.append(pinfo.gate_by_name(sp_name).params)
            else:
                # gate has no pulse parametrization
                self.sp_pulse_params.append(None)

        # --- Encoding ---
        if isinstance(encoding, Encoding):
            # user wants custom strategy? do it!
            self._enc = encoding
        else:
            # use hammming encoding by default
            self._enc = Encoding("hamming", encoding)

        if self._enc.is_golomb:
            self._enc._n_qubits = n_qubits

        # Number of possible inputs
        self.n_input_feat = len(self._enc)
        log.debug(f"Number of input features: {self.n_input_feat}")

        # Trainable frequencies, default initialization as in arXiv:2309.03279v2
        self.enc_params = jnp.ones((self.n_layers, self.n_qubits, self.n_input_feat))

        self._zero_inputs = False

        # --- Data-Reuploading ---

        # Keep as NumPy array (not JAX) so that ``if data_reupload[q, idx]``
        # in _iec remains a concrete Python bool even under jax.jit tracing.
        # note that setting this will also update self.degree and self.frequencies
        # and in consequence also self.has_dru
        self.data_reupload = data_reupload

        # check for the highest degree among all input dimensions
        if self.has_dru:
            impl_n_layers: int = n_layers + 1  # we need L+1 according to Schuld et al.
        else:
            impl_n_layers = n_layers
        log.info(f"Number of implicit layers: {impl_n_layers}.")

        # --- Ansatz ---
        # only weak check for str. We trust the user to provide sth useful
        if isinstance(circuit_type, str):
            self.pqc: Callable[[Optional[jnp.ndarray], int], int] = getattr(
                Ansaetze, circuit_type or "No_Ansatz"
            )()
        else:
            self.pqc = circuit_type()
        log.info(f"Using Ansatz {circuit_type}.")

        # calculate the shape of the parameter vector here, we will re-use this in init.
        params_per_layer = self.pqc.n_params_per_layer(self.n_qubits)
        self._params_shape: Tuple[int, int] = (impl_n_layers, params_per_layer)
        log.info(f"Parameters per layer: {params_per_layer}")

        pulse_params_per_layer = self.pqc.n_pulse_params_per_layer(self.n_qubits)
        self._pulse_params_shape: Tuple[int, int] = (
            impl_n_layers,
            pulse_params_per_layer,
        )

        # intialize to None as we can't know this yet
        self._batch_shape = None

        # this will also be re-used in the init method,
        # however, only if nothing is provided
        self._inialization_strategy = initialization
        self._initialization_domain = initialization_domain

        # ..here! where we only require a JAX random key
        self.random_key = self.initialize_params(random.key(random_seed))

        # Initializing pulse params
        self.pulse_params: jnp.ndarray = jnp.ones((1, *self._pulse_params_shape))

        log.info(f"Initialized pulse parameters with shape {self.pulse_params.shape}.")

        # Initialise the jaqsi Script that wraps _variational.
        # No device selection needed - jaqsi auto-routes between statevector
        # and density-matrix simulation based on whether noise channels are
        # present on the tape.
        self.script = js.Script(f=self._variational, n_qubits=self.n_qubits)

    @property
    def noise_params(self) -> Optional[Dict[str, Union[float, Dict[str, float]]]]:
        """
        Gets the noise parameters of the model.

        Returns:
            Optional[Dict[str, float]]: A dictionary of
            noise parameters or None if not set.
        """
        return self._noise_params

    @noise_params.setter
    def noise_params(
        self, kvs: Optional[Dict[str, Union[float, Dict[str, float]]]]
    ) -> None:
        """
        Sets the noise parameters of the model.

        Typically a "noise parameter" refers to the error probability.
        ThermalRelaxation is a special case, and supports a dict as value with
        structure:
            "ThermalRelaxation":
            {
                "t1": 2000, # relative t1 time.
                "t2": 1000, # relative t2 time
                "t_factor" 1: # relative gate time factor
            },

        Args:
            kvs (Optional[Dict[str, Union[float, Dict[str, float]]]]): A
            dictionary of noise parameters. If all values are 0.0, the noise
            parameters are set to None.

        Returns:
            None
        """
        # set to None if only zero values provided
        if kvs is not None and all(v == 0.0 for v in kvs.values()):
            kvs = None

        # set default values
        if kvs is not None:
            defaults = {
                "BitFlip": 0.0,
                "PhaseFlip": 0.0,
                "Depolarizing": 0.0,
                "MultiQubitDepolarizing": 0.0,
                "AmplitudeDamping": 0.0,
                "PhaseDamping": 0.0,
                "GateError": 0.0,
                "ThermalRelaxation": None,
                "StatePreparation": 0.0,
                "Measurement": 0.0,
            }
            for key, default_val in defaults.items():
                kvs.setdefault(key, default_val)

            # check if there are any keys not supported
            for key in kvs.keys():
                if key not in defaults:
                    warnings.warn(
                        f"Noise type {key} is not supported by this package",
                        UserWarning,
                    )

            # check valid params for thermal relaxation noise channel
            tr_params = kvs["ThermalRelaxation"]
            if isinstance(tr_params, dict):
                tr_params.setdefault("t1", 0.0)
                tr_params.setdefault("t2", 0.0)
                tr_params.setdefault("t_factor", 0.0)
                valid_tr_keys = {"t1", "t2", "t_factor"}
                for k in tr_params.keys():
                    if k not in valid_tr_keys:
                        warnings.warn(
                            f"Thermal Relaxation parameter {k} is not supported "
                            f"by this package",
                            UserWarning,
                        )
                if not all(tr_params.values()) or tr_params["t2"] > 2 * tr_params["t1"]:
                    warnings.warn(
                        "Received invalid values for Thermal Relaxation noise "
                        "parameter. Thermal relaxation is not applied!",
                        UserWarning,
                    )
                    kvs["ThermalRelaxation"] = 0.0

        self._noise_params = kvs

    @property
    def output_qubit(self) -> List[int]:
        """Get the output qubit indices for measurement."""
        return self._output_qubit

    @output_qubit.setter
    def output_qubit(self, value: Union[int, List[int]]) -> None:
        """
        Set the output qubit(s) for measurement.

        Args:
            value: Qubit index or list of indices. Use -1 for all qubits.
        """
        if isinstance(value, list):
            assert len(value) <= self.n_qubits, (
                f"Size of output_qubit {len(value)} cannot be\
            larger than number of qubits {self.n_qubits}."
            )
        elif isinstance(value, int):
            if value == -1:
                value = list(range(self.n_qubits))
            else:
                assert value < self.n_qubits, (
                    f"Output qubit {value} cannot be larger than {self.n_qubits}."
                )
                value = [value]

        self._output_qubit = value

    @property
    def execution_type(self) -> str:
        """
        Gets the execution type of the model.

        Returns:
            str: The execution type, one of 'density', 'expval', or 'probs'.
        """
        return self._execution_type

    @execution_type.setter
    def execution_type(self, value: str) -> None:
        if value == "density":
            self._result_shape = (
                2 ** len(self.output_qubit),
                2 ** len(self.output_qubit),
            )
        elif value == "expval":
            # check if all qubits are used
            if len(self.output_qubit) == self.n_qubits:
                self._result_shape = (len(self.output_qubit),)
            # if not -> parity measurement with only 1D output per pair
            # or n_local measurement
            else:
                self._result_shape = (len(self.output_qubit),)
        elif value == "probs":
            # in case this is a list of parities,
            # each pair has 2^len(qubits) probabilities
            n_parity = (
                (2,) * len(self.output_qubit)
                if isinstance(self.output_qubit, (Tuple, List))
                else (2,)
            )
            self._result_shape = n_parity
        elif value == "state":
            self._result_shape = (2 ** len(self.output_qubit),)
        else:
            raise ValueError(f"Invalid execution type: {value}.")

        if value == "state" and not self.all_qubit_measurement:
            warnings.warn(
                f"{value} measurement does ignore output_qubit, which is "
                f"{self.output_qubit}.",
                UserWarning,
            )

        if value == "probs" and self.shots is None:
            warnings.warn(
                "Setting execution_type to probs without specifying shots.",
                UserWarning,
            )

        if value == "density" and self.shots is not None:
            raise ValueError("Setting execution_type to density with shots not None.")

        self._execution_type = value

    @property
    def shots(self) -> Optional[int]:
        """
        Gets the number of shots to use for the quantum device.

        Returns:
            Optional[int]: The number of shots.
        """
        return self._shots

    @shots.setter
    def shots(self, value: Optional[int]) -> None:
        """
        Sets the number of shots to use for the quantum device.

        Args:
            value (Optional[int]): The number of shots.
            If an integer less than or equal to 0 is provided, it is set to None.

        Returns:
            None
        """
        if type(value) is int and value <= 0:
            value = None
        self._shots = value

    @property
    def params(self) -> jnp.ndarray:
        """Get the variational parameters of the model."""
        return self._params

    @params.setter
    def params(self, value: jnp.ndarray) -> None:
        """Set the variational parameters, ensuring batch dimension exists."""
        if len(value.shape) == 2:
            value = value.reshape(1, *value.shape)

        self._params = value

    @property
    def enc_params(self) -> jnp.ndarray:
        """Get the encoding parameters used for input transformation."""
        return self._enc_params

    @enc_params.setter
    def enc_params(self, value: jnp.ndarray) -> None:
        """Set the encoding parameters."""
        self._enc_params = value

    @property
    def pulse_params(self) -> jnp.ndarray:
        """Get the pulse parameters for pulse-mode gate execution."""
        return self._pulse_params

    @pulse_params.setter
    def pulse_params(self, value: jnp.ndarray) -> None:
        """Set the pulse parameters."""
        self._pulse_params = value

    @property
    def data_reupload(self) -> jnp.ndarray:
        """Get the data reupload mask."""
        return self._data_reupload

    @data_reupload.setter
    def data_reupload(self, value: jnp.ndarray) -> None:
        """Set the data reupload mask.

        Always converts to a concrete NumPy boolean array so that
        ``if data_reupload[q, idx]`` in :meth:`_iec` remains a plain
        Python ``bool`` even inside JAX-traced functions (jit / grad / vmap).
        """
        # Process data reuploading strategy and set degree
        if not isinstance(value, bool):
            if not isinstance(value, np.ndarray):
                value = np.array(value)

            if len(value.shape) == 2:
                assert value.shape == (
                    self.n_layers,
                    self.n_qubits,
                ), (
                    f"Data reuploading array has wrong shape. \
                    Expected {(self.n_layers, self.n_qubits)} or\
                    {(self.n_layers, self.n_qubits, self.n_input_feat)},\
                    got {value.shape}."
                )
                value = value.reshape(*value.shape, 1)
                value = np.repeat(value, self.n_input_feat, axis=2)

            assert value.shape == (
                self.n_layers,
                self.n_qubits,
                self.n_input_feat,
            ), (
                f"Data reuploading array has wrong shape. \
                Expected {(self.n_layers, self.n_qubits, self.n_input_feat)},\
                got {value.shape}."
            )

            log.debug(f"Data reuploading array:\n{value}")
        else:
            if value:
                value = np.ones((self.n_layers, self.n_qubits, self.n_input_feat))
                log.debug("Full data reuploading.")
            else:
                value = np.zeros((self.n_layers, self.n_qubits, self.n_input_feat))
                value[0][0] = 1
                log.debug("No data reuploading.")

        # convert to boolean values
        self._data_reupload = np.asarray(value).astype(bool)

        self.degree: Tuple = tuple(
            self._enc.get_n_freqs(np.count_nonzero(self.data_reupload[..., i]))
            for i in range(self.n_input_feat)
        )

        self.frequencies: Tuple = tuple(
            self._enc.get_spectrum(np.count_nonzero(self.data_reupload[..., i]))
            for i in range(self.n_input_feat)
        )

        # Cache has_dru as a plain Python bool so that it can be used in
        # Python ``if`` statements even inside JAX-traced functions.
        self._has_dru: bool = bool(max(int(np.max(f)) for f in self._frequencies) > 1)

    @property
    def degree(self) -> Tuple:
        """Get the degree of the model."""
        return self._degree

    @degree.setter
    def degree(self, value: Tuple):
        self._degree = value

    @property
    def frequencies(self) -> Tuple:
        """Get the frequencies of the model."""
        return self._frequencies

    @frequencies.setter
    def frequencies(self, value: Tuple):
        self._frequencies = value

    def exact_spectrum(self, method: str = "tree") -> Tuple[np.ndarray, ...]:
        """Compute the exact per-feature Fourier spectrum via the FourierTree.

        Unlike :attr:`frequencies` -- a naive per-feature estimate derived purely
        from the encoding, which can *overestimate* the spectrum (some
        coefficients are constrained to zero for all parameters) -- this builds
        the analytical Fourier tree (Nemkov et al.) and returns, for each input
        feature, the integer frequencies whose Fourier coefficient is not
        identically zero.  The result is always a subset of :attr:`frequencies`.

        The support is derived purely symbolically (no parameter sampling): see
        :meth:`~qml_essentials.coefficients.FourierTree.get_exact_support`.
        With ``method="tree"`` (default), frequencies whose contributions cancel
        identically across tree paths (e.g. two consecutive encodings combining
        into a single rotation) are excluded exactly; this enumerates the
        explicit tree, which can be infeasible for deep entangling circuits.
        With ``method="dp"``, a merged-state dynamic program derives the support
        without enumerating paths, which scales to deep circuits (single input
        feature only) at the cost of not detecting identical cross-path
        cancellations.

        Requires a Clifford + Pauli-rotation ansatz (see
        :class:`~qml_essentials.pauli.PauliCircuit`); other gate sets raise
        ``NotImplementedError`` during tree construction.

        Args:
            method (str): ``"tree"`` (fully exact) or ``"dp"`` (scalable).

        Returns:
            Tuple[np.ndarray, ...]: One sorted integer frequency array per input
            feature (same layout as :attr:`frequencies`).
        """
        from qml_essentials.coefficients import FourierTree  # avoid circular imp.

        tree = FourierTree(self)

        # Position of each model feature within the tree's frequency vectors.
        feature_pos = {feat: i for i, feat in enumerate(tree.features)}

        # Union of the symbolic supports over all observables (roots).
        support = set()
        for freqs in tree.get_exact_support(method=method):
            farr = np.asarray(freqs)
            for k in range(farr.shape[0]):
                key = (
                    (int(farr[k]),)
                    if farr.ndim == 1
                    else tuple(int(v) for v in farr[k])
                )
                support.add(key)

        spectrum = []
        for feat in range(self.n_input_feat):
            if support and feat in feature_pos:
                pos = feature_pos[feat]
                vals = sorted({k[pos] for k in support})
            else:
                vals = [0]
            spectrum.append(np.array(vals, dtype=int))
        return tuple(spectrum)

    @property
    def has_dru(self) -> bool:
        """Check if the model has data reupload."""
        return self._has_dru

    @property
    def all_qubit_measurement(self) -> bool:
        """Check if measurement is performed on all qubits."""
        return self.output_qubit == list(range(self.n_qubits))

    @property
    def batch_shape(self) -> Tuple[int, ...]:
        """
        Get the batch shape (B_I, B_P, B_R).
        If the model was not called before,
        it returns (1, 1, 1).

        Returns:
            Tuple[int, ...]: Tuple of (input_batch, param_batch, pulse_batch).
                Returns (1, 1, 1) if model has not been called yet.
        """
        if self._batch_shape is None:
            log.debug("Model was not called yet. Returning (1,1,1) as batch shape.")
            return (1, 1, 1)
        return self._batch_shape

    @property
    def eff_batch_shape(self) -> Tuple[int, ...]:
        """
        Get the effective batch shape after applying repeat_batch_axis mask.

        Returns:
            Tuple[int, ...]: Effective batch dimensions, excluding zeros.
        """
        batch_shape = np.array(self.batch_shape) * self.repeat_batch_axis
        batch_shape = batch_shape[batch_shape != 0]
        return batch_shape

    def initialize_params(
        self,
        random_key: Optional[random.PRNGKey] = None,
        repeat: int = 1,
        initialization: Optional[str] = None,
        initialization_domain: Optional[List[float]] = None,
    ) -> random.PRNGKey:
        """
        Initialize the variational parameters of the model.

        Args:
            random_key (Optional[random.PRNGKey]): JAX random key for initialization.
                If None, uses the model's internal random key.
            repeat (int): Number of parameter sets to create (batch dimension).
                Defaults to 1.
            initialization (Optional[str]): Strategy for parameter initialization.
                Options: "random", "zeros", "pi", "zero-controlled", "pi-controlled".
                If None, uses the strategy specified in the constructor.
            initialization_domain (Optional[List[float]]): Domain [min, max] for
                random initialization. If None, uses the domain from constructor.

        Returns:
            random.PRNGKey: Updated random key after initialization.

        Raises:
            Exception: If an invalid initialization method is specified.
        """
        # Initializing params
        params_shape = (repeat, *self._params_shape)

        # use existing strategy if not specified
        initialization = initialization or self._inialization_strategy
        initialization_domain = initialization_domain or self._initialization_domain

        random_key, sub_key = safe_random_split(
            random_key if random_key is not None else self.random_key
        )

        def set_control_params(params: jnp.ndarray, value: float) -> jnp.ndarray:
            indices = self.pqc.get_control_indices(self.n_qubits)
            if indices is None:
                warnings.warn(
                    f"Specified {initialization} but circuit\
                    does not contain controlled rotation gates.\
                    Parameters are intialized randomly.",
                    UserWarning,
                )
            else:
                np_params = np.array(params)
                np_params[:, :, indices[0] : indices[1] : indices[2]] = (
                    np.ones_like(params[:, :, indices[0] : indices[1] : indices[2]])
                    * value
                )
                params = jnp.array(np_params)
            return params

        if initialization == "random":
            self.params: jnp.ndarray = random.uniform(
                sub_key,
                params_shape,
                minval=initialization_domain[0],
                maxval=initialization_domain[1],
            )
        elif initialization == "zeros":
            self.params: jnp.ndarray = jnp.zeros(params_shape)
        elif initialization == "pi":
            self.params: jnp.ndarray = jnp.ones(params_shape) * jnp.pi
        elif initialization == "zero-controlled":
            self.params: jnp.ndarray = random.uniform(
                sub_key,
                params_shape,
                minval=initialization_domain[0],
                maxval=initialization_domain[1],
            )
            self.params = set_control_params(self.params, 0)
        elif initialization == "pi-controlled":
            self.params: jnp.ndarray = random.uniform(
                sub_key,
                params_shape,
                minval=initialization_domain[0],
                maxval=initialization_domain[1],
            )
            self.params = set_control_params(self.params, jnp.pi)
        else:
            raise Exception("Invalid initialization method")

        log.info(
            f"Initialized parameters with shape {self.params.shape}\
            using strategy {initialization}."
        )

        return random_key

    def transform_input(
        self, inputs: jnp.ndarray, enc_params: jnp.ndarray
    ) -> jnp.ndarray:
        """
        Transform input data by scaling with encoding parameters.

        Implements the input transformation as described in arXiv:2309.03279v2,
        where inputs are linearly scaled by encoding parameters before being
        used in the quantum circuit.

        Args:
            inputs (jnp.ndarray): Input data point of shape (n_input_feat,) or
                (batch_size, n_input_feat).
            enc_params (jnp.ndarray): Encoding weight scalar or vector used to
                scale the input.

        Returns:
            jnp.ndarray: Transformed input, element-wise product of inputs
                and enc_params.
        """
        return inputs * enc_params

    def _iec(
        self,
        inputs: jnp.ndarray,
        data_reupload: jnp.ndarray,
        enc: Encoding,
        enc_params: jnp.ndarray,
        noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
        random_key: Optional[random.PRNGKey] = None,
    ) -> None:
        """
        Apply Input Encoding Circuit (IEC) with angle encoding.

        Encodes classical input data into the quantum circuit using rotation
        gates (e.g., RX, RY, RZ). Supports data re-uploading at specified
        positions in the circuit.

        For Golomb encoding, a single multi-qubit diagonal unitary is applied
        to all qubits simultaneously instead of per-qubit rotation gates.

        Args:
            inputs (jnp.ndarray): Input data of shape (n_input_feat,) or
                (batch_size, n_input_feat).
            data_reupload (jnp.ndarray): Boolean array of shape (n_qubits, n_input_feat)
                indicating where to apply encoding gates.
            enc (Encoding): Encoding strategy containing the encoding gate functions.
            enc_params (jnp.ndarray): Encoding parameters of shape
                (n_qubits, n_input_feat) used to scale inputs.
            noise_params (Optional[Dict[str, Union[float, Dict[str, float]]]]):
                Noise parameters for gate-level noise simulation. Defaults to None.
            random_key (Optional[random.PRNGKey]): JAX random key for stochastic
                noise. Defaults to None.

        Returns:
            None: Gates are applied in-place to the quantum circuit.
        """
        # check for zero, because due to input validation, input cannot be none
        if self.remove_zero_encoding and self._zero_inputs and self.batch_shape[0] == 1:
            return

        # --- Golomb encoding: single multi-qubit gate on all qubits --------
        if enc.is_golomb:
            idx = 0  # Golomb encoding supports a single input feature
            # Check if any qubit has re-uploading enabled for this layer
            if data_reupload[:, idx].any():
                random_key, sub_key = safe_random_split(random_key)
                # Use the mean of enc_params across qubits as scalar scaling
                # (Golomb acts on all qubits jointly)
                mean_enc_param = jnp.mean(enc_params[:, idx])
                all_wires = list(range(self.n_qubits))
                enc[idx](
                    self.transform_input(inputs[..., idx], mean_enc_param),
                    wires=all_wires,
                    noise_params=noise_params,
                    random_key=sub_key,
                )
            return

        # --- Standard per-qubit encoding -----------------------------------
        for q in range(self.n_qubits):
            # use the last dimension of the inputs (feature dimension)
            for idx in range(inputs.shape[-1]):
                if data_reupload[q, idx]:
                    # use elipsis to indiex only the last dimension
                    # as inputs are generally *not* qubit dependent
                    random_key, sub_key = safe_random_split(random_key)
                    enc[idx](
                        self.transform_input(inputs[..., idx], enc_params[q, idx]),
                        wires=q,
                        noise_params=noise_params,
                        random_key=sub_key,
                    )

    def _variational(
        self,
        params: jnp.ndarray,
        inputs: jnp.ndarray,
        pulse_params: Optional[jnp.ndarray] = None,
        random_key: Optional[random.PRNGKey] = None,
        enc_params: Optional[jnp.ndarray] = None,
        gate_mode: str = "unitary",
        noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
    ) -> None:
        """
        Build the variational quantum circuit structure.

        Constructs the circuit by applying state preparation, alternating
        variational ansatz layers with input encoding layers, and optional
        noise channels.

        The first five parameters (after ``self``) - ``params``, ``inputs``,
        ``pulse_params``, ``random_key``, ``enc_params`` - are the batchable
        positional arguments.
        The remaining keyword arguments are broadcast across the batch.

        Args:
            params (jnp.ndarray): Variational parameters of shape
                (n_layers, n_params_per_layer).
            inputs (jnp.ndarray): Input data of shape (n_input_feat,).
            pulse_params (Optional[jnp.ndarray]): Pulse parameter scalers of shape
                (n_layers, n_pulse_params_per_layer) for pulse-mode execution.
                Defaults to None (uses model's pulse_params).
            random_key (Optional[random.PRNGKey]): JAX random key for stochastic
                operations. Defaults to None.
            enc_params (Optional[jnp.ndarray]): Encoding parameters of shape
                (n_qubits, n_input_feat). Defaults to None (uses model's enc_params).
            gate_mode (str): Gate execution mode, either "unitary" or "pulse".
                Defaults to "unitary".
            noise_params (Optional[Dict[str, Union[float, Dict[str, float]]]]):
                Noise parameters for simulation. Defaults to None.

        Returns:
            None: Gates are applied in-place to the quantum circuit.

        Note:
            Issues RuntimeWarning if called directly without providing parameters
            that would normally be passed through the forward method.
        """
        # TODO: rework and double check params shape
        if len(params.shape) > 2 and params.shape[0] == 1:
            params = params[0]

        if len(inputs.shape) > 1 and inputs.shape[0] == 1:
            inputs = inputs[0]

        if enc_params is None:
            # TODO: Raise warning if trainable frequencies is True, or similar. I.e., no
            #   warning if user does not care for frequencies or enc_params
            if self.trainable_frequencies:
                warnings.warn(
                    "Explicit call to `_circuit` or `_variational` detected: "
                    "`enc_params` is None, using `self.enc_params` instead.",
                    RuntimeWarning,
                )
            enc_params = self.enc_params

        if pulse_params is None:
            if gate_mode == "pulse":
                warnings.warn(
                    "Explicit call to `_circuit` or `_variational` detected: "
                    "`pulse_params` is None, using `self.pulse_params` instead.",
                    RuntimeWarning,
                )
            pulse_params = self.pulse_params

        # Squeeze batch dimension for pulse_params (batch-first convention)
        if len(pulse_params.shape) > 2 and pulse_params.shape[0] == 1:
            pulse_params = pulse_params[0]

        if noise_params is None:
            if self.noise_params is not None:
                warnings.warn(
                    "Explicit call to `_circuit` or `_variational` detected: "
                    "`noise_params` is None, using `self.noise_params` instead.",
                    RuntimeWarning,
                )
                noise_params = self.noise_params

        if noise_params is not None:
            if random_key is None:
                warnings.warn(
                    "Explicit call to `_circuit` or `_variational` detected: "
                    "`random_key` is None, using `random.PRNGKey(0)` instead.",
                    RuntimeWarning,
                )
                random_key = self.random_key
            self._apply_state_prep_noise(noise_params=noise_params)

        # state preparation
        for q in range(self.n_qubits):
            for _sp, sp_pulse_params in zip(self._sp, self.sp_pulse_params):
                random_key, sub_key = safe_random_split(random_key)
                _sp(
                    wires=q,
                    pulse_params=sp_pulse_params,
                    noise_params=noise_params,
                    random_key=sub_key,
                    gate_mode=gate_mode,
                )

        # circuit building
        for layer in range(0, self.n_layers):
            random_key, sub_key = safe_random_split(random_key)
            # ansatz layers
            self.pqc(
                params[layer],
                self.n_qubits,
                pulse_params=pulse_params[layer],
                noise_params=noise_params,
                random_key=sub_key,
                gate_mode=gate_mode,
            )

            random_key, sub_key = safe_random_split(random_key)
            # encoding layers
            self._iec(
                inputs,
                data_reupload=self.data_reupload[layer],
                enc=self._enc,
                enc_params=enc_params[layer],
                noise_params=noise_params,
                random_key=sub_key,
            )

        # final ansatz layer
        if self.has_dru:  # same check as in init
            random_key, sub_key = safe_random_split(random_key)
            self.pqc(
                params[self.n_layers],
                self.n_qubits,
                pulse_params=pulse_params[-1],
                noise_params=noise_params,
                random_key=sub_key,
                gate_mode=gate_mode,
            )

        # channel noise
        if noise_params is not None:
            self._apply_general_noise(noise_params=noise_params)

    def _build_obs(self) -> Tuple[str, List[op.Operation]]:
        """Build the jaqsi measurement type and observable list.

        Translates the model's ``execution_type`` and ``output_qubit``
        settings into parameters suitable for
        :meth:`~qml_essentials.jaqsi.Script.execute`.

        Returns:
            Tuple ``(meas_type, obs)`` where *meas_type* is one of
            ``"expval"``, ``"probs"``, ``"density"``, ``"state"`` and *obs*
            is a (possibly empty) list of :class:`Operation` observables.
        """
        if self.execution_type == "density":
            return "density", []

        if self.execution_type == "state":
            return "state", []

        if self.execution_type == "expval":
            obs: List[op.Operation] = []
            for qubit_spec in self.output_qubit:
                if isinstance(qubit_spec, int):
                    obs.append(op.PauliZ(wires=qubit_spec))
                else:
                    # parity: Z \\otimes Z \\otimes …
                    obs.append(js.build_parity_observable(list(qubit_spec)))
            return "expval", obs

        if self.execution_type == "probs":
            # probs are computed on the full system; subsystem
            # marginalisation is handled in _postprocess_res
            return "probs", []

        raise ValueError(f"Invalid execution_type: {self.execution_type}.")

    def _apply_state_prep_noise(
        self, noise_params: Dict[str, Union[float, Dict[str, float]]]
    ) -> None:
        """
        Apply state preparation noise to all qubits.

        Simulates imperfect state preparation by applying BitFlip errors
        to each qubit with the specified probability.

        Args:
            noise_params (Dict[str, Union[float, Dict[str, float]]]): Dictionary
                containing noise parameters. Uses the "StatePreparation" key
                for the BitFlip probability.

        Returns:
            None: Noise channels are applied in-place to the circuit.
        """
        p = noise_params.get("StatePreparation", 0.0)
        if p > 0:
            for q in range(self.n_qubits):
                op.BitFlip(p, wires=q)

    def _apply_general_noise(
        self, noise_params: Dict[str, Union[float, Dict[str, float]]]
    ) -> None:
        """
        Apply general noise channels to all qubits.

        Applies various decoherence and error channels after the circuit
        execution, simulating environmental noise effects.

        Args:
            noise_params (Dict[str, Union[float, Dict[str, float]]]): Dictionary
                containing noise parameters with the following supported keys:
                - "AmplitudeDamping" (float): Probability for amplitude damping.
                - "PhaseDamping" (float): Probability for phase damping.
                - "Measurement" (float): Probability for measurement error (BitFlip).
                - "ThermalRelaxation" (Dict): Dictionary with keys "t1", "t2",
                  "t_factor" for thermal relaxation simulation.

        Returns:
            None: Noise channels are applied in-place to the circuit.

        Note:
            Gate-level noise (e.g., GateError) is handled separately in the
            Gates.Noise module and applied at the individual gate level.
        """
        amp_damp = noise_params.get("AmplitudeDamping", 0.0)
        phase_damp = noise_params.get("PhaseDamping", 0.0)
        thermal_relax = noise_params.get("ThermalRelaxation", 0.0)
        meas = noise_params.get("Measurement", 0.0)
        for q in range(self.n_qubits):
            if amp_damp > 0:
                op.AmplitudeDamping(amp_damp, wires=q)
            if phase_damp > 0:
                op.PhaseDamping(phase_damp, wires=q)
            if meas > 0:
                op.BitFlip(meas, wires=q)
            if isinstance(thermal_relax, dict):
                t1 = thermal_relax["t1"]
                t2 = thermal_relax["t2"]
                t_factor = thermal_relax["t_factor"]
                circuit_depth = self._get_circuit_depth()
                tg = circuit_depth * t_factor
                op.ThermalRelaxationError(1.0, t1, t2, tg, q)

    def _get_circuit_depth(self, inputs: Optional[jnp.ndarray] = None) -> int:
        """
        Calculate the depth of the quantum circuit.

        Records the circuit onto a tape (without noise) and computes the
        depth as the length of the critical path: each gate is scheduled
        at the earliest time step after all of its qubits are free.

        Args:
            inputs (Optional[jnp.ndarray]): Input data for circuit evaluation.
                If None, default zero inputs are used.

        Returns:
            int: The circuit depth (longest path of gates in the circuit).
        """
        # Return cached value if available
        if hasattr(self, "_cached_circuit_depth"):
            return self._cached_circuit_depth

        inputs = self._inputs_validation(inputs)

        # Temporarily clear noise_params to prevent _variational from
        # picking them up (which would call _apply_general_noise ->
        # _get_circuit_depth again, causing infinite recursion).
        saved_noise = self._noise_params
        self._noise_params = None

        with recording() as tape:
            self._variational(
                self.params[0] if self.params.ndim == 3 else self.params,
                inputs[0] if inputs.ndim == 2 else inputs,
                noise_params=None,
            )

        self._noise_params = saved_noise

        # Filter out noise channels - only count unitary gates
        ops = [o for o in tape if not isinstance(o, KrausChannel)]

        if not ops:
            self._cached_circuit_depth = 0
            return 0

        # Schedule each gate at the earliest time step where all its wires
        # are free.  ``wire_busy[q]`` tracks the next free time step for
        # qubit ``q``.
        wire_busy: Dict[int, int] = {}
        depth = 0
        for gate in ops:
            start = max((wire_busy.get(w, 0) for w in gate.wires), default=0)
            end = start + 1
            for w in gate.wires:
                wire_busy[w] = end
            depth = max(depth, end)

        self._cached_circuit_depth = depth
        return depth

    def draw(
        self,
        inputs: Optional[jnp.ndarray] = None,
        figure: str = "text",
        **kwargs: Any,
    ) -> Union[str, Any]:
        """Visualize the quantum circuit.

        Records the circuit tape (without noise) and renders the gate
        sequence using the requested backend.

        Args:
            inputs (Optional[jnp.ndarray]): Input data for the circuit.
                If ``None``, default zero inputs are used.
            figure (str): Rendering backend.  One of:

                * ``"text"``  - ASCII art (returned as a ``str``).
                * ``"mpl"``   - Matplotlib figure (returns ``(fig, ax)``).
                * ``"tikz"``  - LaTeX/TikZ ``quantikz`` code (returns a
                  :class:`TikzFigure`).
                * ``"pulse"`` - Pulse schedule (returns ``(fig, axes)``).
                  Only meaningful for pulse-mode models.

            **kwargs: Extra options forwarded to the drawing backend
                (e.g. ``gate_values=True``).

        Returns:
            Depends on figure:

            * ``"text"``  -> ``str``
            * ``"mpl"``   -> ``(matplotlib.figure.Figure, matplotlib.axes.Axes)``
            * ``"tikz"``  -> :class:`TikzFigure`

        Raises:
            ValueError: If figure is not one of the supported modes.
        """
        inputs = self._inputs_validation(inputs)
        params = self.params[0] if self.params.ndim == 3 else self.params
        inp = inputs[0] if inputs.ndim == 2 else inputs

        if figure == "pulse":
            return self.draw_pulse(inputs=inputs, **kwargs)

        # Record without noise to get a clean circuit
        saved_noise = self._noise_params
        self._noise_params = None

        draw_script = js.Script(f=self._variational, n_qubits=self.n_qubits)
        result = draw_script.draw(
            figure=figure,
            args=(params, inp),
            kwargs={"noise_params": None},
            **kwargs,
        )

        self._noise_params = saved_noise
        return result

    def draw_pulse(
        self,
        inputs: Optional[jnp.ndarray] = None,
        **kwargs: Any,
    ) -> Any:
        """Visualize the pulse schedule for the circuit.

        Records the circuit in pulse mode and collects PulseEvents
        automatically via the pulse-event tape, then renders them.

        Args:
            inputs: Input data.  If ``None``, default zero inputs are used.
            **kwargs: Forwarded to
                :func:`~qml_essentials.drawing.draw_pulse_schedule`
                (e.g. ``show_carrier=True``, ``n_samples=300``).

        Returns:
            ``(fig, axes)`` — Matplotlib Figure and array of Axes.
        """
        inputs = self._inputs_validation(inputs)
        params = self.params[0] if self.params.ndim == 3 else self.params
        inp = inputs[0] if inputs.ndim == 2 else inputs

        draw_script = js.Script(f=self._variational, n_qubits=self.n_qubits)
        return draw_script.draw(
            figure="pulse",
            args=(params, inp),
            kwargs={
                "gate_mode": "pulse",
                "noise_params": None,
            },
            **kwargs,
        )

    def __repr__(self) -> str:
        """Return text representation of the quantum circuit model."""
        return self.draw(figure="text")

    def __str__(self) -> str:
        """Return string representation of the quantum circuit model."""
        return self.draw(figure="text")

    def _params_validation(self, params: Optional[jnp.ndarray]) -> jnp.ndarray:
        """
        Validate and normalize variational parameters.

        Ensures parameters have the correct shape with a batch dimension,
        and updates the model's internal parameters if new ones are provided.

        Args:
            params (Optional[jnp.ndarray]): Variational parameters to validate.
                If None, returns the model's current parameters.

        Returns:
            jnp.ndarray: Validated parameters with shape
                (batch_size, n_layers, n_params_per_layer).
        """
        # append batch axis if not provided
        if params is not None:
            if len(params.shape) == 2:
                params = np.expand_dims(params, axis=0)

            # Avoid stashing JAX tracers on ``self``: under an outer
            # transform (e.g. ``jacrev``) the tracer becomes invalid once
            # the transform returns, and a subsequent read of
            # ``self.params`` would feed a leaked tracer into the next
            # call (raising ``UnexpectedTracerError``).
            # if not isinstance(params, jax.core.Tracer):
            #     self.params = params
            self.params = params
        else:
            params = self.params

        return params

    def _pulse_params_validation(
        self, pulse_params: Optional[jnp.ndarray]
    ) -> jnp.ndarray:
        """
        Validate and normalize pulse parameters.

        Ensures pulse parameters are set, using model defaults if not provided.

        Args:
            pulse_params (Optional[jnp.ndarray]): Pulse parameter scalers.
                If None, returns the model's current pulse parameters.

        Returns:
            jnp.ndarray: Validated pulse parameters with shape
                (batch_size, n_layers, n_pulse_params_per_layer).
        """
        if pulse_params is None:
            pulse_params = self.pulse_params
        else:
            # ensure batch dimension exists (batch-first convention)
            if len(pulse_params.shape) == 2:
                pulse_params = jnp.expand_dims(pulse_params, axis=0)
            # See note in _params_validation: never stash JAX tracers on
            # ``self``.
            # if not isinstance(pulse_params, jax.core.Tracer):
            #     self.pulse_params = pulse_params
            self.pulse_params = pulse_params

        return pulse_params

    def _enc_params_validation(self, enc_params: Optional[jnp.ndarray]) -> jnp.ndarray:
        """
        Validate and normalize encoding parameters.

        Ensures encoding parameters have the correct shape for the model's
        input feature dimensions.

        Args:
            enc_params (Optional[jnp.ndarray]): Encoding parameters to validate.
                If None, returns the model's current encoding parameters.

        Returns:
            jnp.ndarray: Validated encoding parameters with shape
                (n_qubits, n_input_feat).

        Raises:
            ValueError: If enc_params shape is incompatible with n_input_feat > 1.
        """
        if enc_params is None:
            enc_params = self.enc_params
        else:
            # See note in _params_validation: never stash JAX tracers on
            # ``self``.
            # if not isinstance(enc_params, jax.core.Tracer):
            #     if self.trainable_frequencies:
            #         self.enc_params = enc_params
            #     else:
            #         self.enc_params = jnp.array(enc_params)
            if self.trainable_frequencies:
                self.enc_params = enc_params
            else:
                self.enc_params = jnp.array(enc_params)

        if len(enc_params.shape) == 1 and self.n_input_feat == 1:
            enc_params = enc_params.reshape(-1, 1)
        elif len(enc_params.shape) == 1 and self.n_input_feat > 1:
            raise ValueError(
                f"Input dimension {self.n_input_feat} >1 but \
                `enc_params` has shape {enc_params.shape}"
            )

        return enc_params

    def _inputs_validation(
        self, inputs: Union[None, List, float, int, jnp.ndarray]
    ) -> jnp.ndarray:
        """
        Validate and normalize input data.

        Converts various input formats to a standardized 2D array shape
        suitable for batch processing in the quantum circuit.

        Args:
            inputs (Union[None, List, float, int, jnp.ndarray]): Input data in
                various formats:
                - None: Returns zeros with shape (1, n_input_feat)
                - float/int: Single scalar value
                - List: List of values or batched inputs
                - jnp.ndarray: NumPy/JAX array

        Returns:
            jnp.ndarray: Validated inputs with shape (batch_size, n_input_feat).

        Raises:
            ValueError: If input shape is incompatible with expected n_input_feat.

        Warns:
            UserWarning: If input is replicated to match n_input_feat.
        """
        self._zero_inputs = False
        if isinstance(inputs, List):
            inputs = jnp.array(np.stack(inputs))
        elif isinstance(inputs, float) or isinstance(inputs, int):
            inputs = jnp.array([inputs])
        elif inputs is None:
            inputs = jnp.array([[0] * self.n_input_feat])

        if not inputs.any():
            self._zero_inputs = True

        if len(inputs.shape) <= 1:
            if self.n_input_feat == 1:
                # add a batch dimension
                inputs = inputs.reshape(-1, 1)
            else:
                if inputs.shape[0] == self.n_input_feat:
                    inputs = inputs.reshape(1, -1)
                else:
                    inputs = inputs.reshape(-1, 1)
                    inputs = inputs.repeat(self.n_input_feat, axis=1)
                    warnings.warn(
                        f"Expected {self.n_input_feat} inputs, but {inputs.shape[0]} "
                        "was provided, replicating input for all input features.",
                        UserWarning,
                    )
        else:
            if inputs.shape[1] != self.n_input_feat:
                raise ValueError(
                    f"Wrong number of inputs provided. Expected {self.n_input_feat} "
                    f"inputs, but input has shape {inputs.shape}."
                )

        return inputs

    def _postprocess_res(self, result: Union[List, jnp.ndarray]) -> jnp.ndarray:
        """
        Post-process circuit execution results for uniform shape.

        Converts list outputs (from multiple measurements) to stacked arrays
        and reorders axes for consistent batch dimension placement.

        Args:
            result (Union[List, jnp.ndarray]): Raw circuit output, either a
                list of measurement results or a single array.

        Returns:
            jnp.ndarray: Uniformly shaped result array with batch dimension first.
        """
        if isinstance(result, list):
            # we use moveaxis here because in case of parity measure,
            # there is another dimension appended to the end and
            # simply transposing would result in a wrong shape
            result = jnp.stack(result)
            if len(result.shape) > 1:
                result = jnp.moveaxis(result, 0, 1)
        return result

    def _assimilate_batch(
        self,
        inputs: jnp.ndarray,
        params: jnp.ndarray,
        pulse_params: jnp.ndarray,
    ) -> Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]:
        """
        Align batch dimensions across inputs, parameters, and pulse parameters.

        Broadcasts and reshapes arrays to have compatible batch dimensions
        for vectorized circuit execution. Sets the internal batch_shape.

        Args:
            inputs (jnp.ndarray): Input data of shape (B_I, n_input_feat).
            params (jnp.ndarray): Parameters of shape (B_P, n_layers, n_params).
            pulse_params (jnp.ndarray): Pulse params of shape (B_R, n_layers, n_pulse).

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: Tuple containing:
                - inputs: Reshaped to (B, n_input_feat) where B = B_I * B_P * B_R
                - params: Reshaped to (B, n_layers, n_params)
                - pulse_params: Reshaped to (B, n_layers, n_pulse)

        Note:
            The effective batch shape depends on repeat_batch_axis configuration.
            This is the only method that sets self._batch_shape.
        """
        B_I = inputs.shape[0]
        # we check for the product because there is a chance that
        # there are no params. In this case we want B_P to be 1
        B_P = 1 if 0 in params.shape else params.shape[0]
        B_R = pulse_params.shape[0]

        # THIS is the only place where we set the batch shape
        self._batch_shape = (B_I, B_P, B_R)
        B = np.prod(self.eff_batch_shape)

        # [B_I, ...] -> [B_I, B_P, B_R, ...] -> [B, ...]
        if B_I > 1 and self.repeat_batch_axis[0]:
            if self.repeat_batch_axis[1]:
                inputs = jnp.repeat(inputs[:, None, None, ...], B_P, axis=1)
            if self.repeat_batch_axis[2]:
                inputs = jnp.repeat(inputs, B_R, axis=2)
            inputs = inputs.reshape(B, *inputs.shape[3:])

        # [B_P, ..., ...] -> [B_I, B_P, B_R, ..., ...] -> [B, ..., ...]
        if B_P > 1 and self.repeat_batch_axis[1]:
            # add B_I axis before first, and B_R axis after first batch dim
            params = params[None, :, None, ...]  # [B_I(=1), B_P, B_R(=1), ...]
            if self.repeat_batch_axis[0]:
                params = jnp.repeat(params, B_I, axis=0)  # [B_I, B_P, 1, ...]
            if self.repeat_batch_axis[2]:
                params = jnp.repeat(params, B_R, axis=2)  # [B_I, B_P, B_R, ...]
            params = params.reshape(B, *params.shape[3:])

        # [B_R, ..., ...] -> [B_I, B_P, B_R, ..., ...] -> [B, ..., ...]
        if B_R > 1 and self.repeat_batch_axis[2]:
            # add B_I axis and B_P axis before B_R
            pulse_params = pulse_params[None, None, ...]  # [B_I(=1), B_P(=1), B_R, ...]
            if self.repeat_batch_axis[0]:
                pulse_params = jnp.repeat(
                    pulse_params, B_I, axis=0
                )  # [B_I, 1, B_R, ...]
            if self.repeat_batch_axis[1]:
                pulse_params = jnp.repeat(
                    pulse_params, B_P, axis=1
                )  # [B_I, B_P, B_R, ...]
            pulse_params = pulse_params.reshape(B, *pulse_params.shape[3:])

        return inputs, params, pulse_params

    def _requires_density(self) -> bool:
        """
        Check if density matrix simulation is required.

        Determines whether the circuit must be executed with the mixed-state
        simulator based on execution type and noise configuration.

        Returns:
            bool: True if density matrix simulation is required, False otherwise.
                Returns True if:
                - execution_type is "density", or
                - Any non-coherent noise channel has non-zero probability
        """
        if self.execution_type == "density":
            return True

        if self.noise_params is None:
            return False

        coherent_noise = {"GateError"}
        for k, v in self.noise_params.items():
            if k in coherent_noise:
                continue
            if v is not None and v > 0:
                return True
        return False

    def __call__(
        self,
        params: Optional[jnp.ndarray] = None,
        inputs: Optional[jnp.ndarray] = None,
        pulse_params: Optional[jnp.ndarray] = None,
        enc_params: Optional[jnp.ndarray] = None,
        data_reupload: Union[bool, List[List[bool]], List[List[List[bool]]]] = None,
        noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
        execution_type: Optional[str] = None,
        force_mean: bool = False,
        gate_mode: str = "unitary",
    ) -> jnp.ndarray:
        """
        Execute the quantum circuit (callable interface).

        Provides a convenient callable interface for circuit execution,
        delegating to the _forward method.

        Args:
            params (Optional[jnp.ndarray]): Variational parameters of shape
                (n_layers, n_params_per_layer) or (batch, n_layers, n_params_per_layer).
                If None, uses model's internal parameters.
            inputs (Optional[jnp.ndarray]): Input data of shape
                (batch_size, n_input_feat). If None, uses zero inputs.
            pulse_params (Optional[jnp.ndarray]): Pulse parameter scalers for
                pulse-mode gate execution.
            enc_params (Optional[jnp.ndarray]): Encoding parameters of shape
                (n_qubits, n_input_feat). If None, uses model's encoding parameters.
            data_reupload (Union[bool, List[List[bool]], List[List[List[bool]]]]):
                Data reupload configuration. If None, uses previously set reupload
                configuration.
            noise_params (Optional[Dict[str, Union[float, Dict[str, float]]]]):
                Noise configuration. If None, uses previously set noise parameters.
            execution_type (Optional[str]): Measurement type: "expval", "density",
                "probs", or "state". If None, uses current execution_type setting.
            force_mean (bool): If True, averages results over measurement qubits.
                Defaults to False.
            gate_mode (str): Gate execution backend, "unitary" or "pulse".
                Defaults to "unitary".

        Returns:
            jnp.ndarray: Circuit output with shape depending on execution_type:
                - "expval": (n_output_qubits,) or scalar
                - "density": (2^n_output, 2^n_output)
                - "probs": (2^n_output,) or (n_pairs, 2^pair_size)
                - "state": (2^n_qubits,)
        """
        # Call forward method which handles the actual caching etc.
        return self._forward(
            params=params,
            inputs=inputs,
            pulse_params=pulse_params,
            enc_params=enc_params,
            data_reupload=data_reupload,
            noise_params=noise_params,
            execution_type=execution_type,
            force_mean=force_mean,
            gate_mode=gate_mode,
        )

    def _forward(
        self,
        params: Optional[jnp.ndarray] = None,
        inputs: Optional[jnp.ndarray] = None,
        pulse_params: Optional[jnp.ndarray] = None,
        enc_params: Optional[jnp.ndarray] = None,
        data_reupload: Union[bool, List[List[bool]], List[List[List[bool]]]] = None,
        noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
        execution_type: Optional[str] = None,
        force_mean: bool = False,
        gate_mode: str = "unitary",
    ) -> jnp.ndarray:
        """
        Execute the quantum circuit forward pass.

        Internal implementation of the forward pass that handles parameter
        validation, batch alignment, and circuit execution routing.

        Args:
            params (Optional[jnp.ndarray]): Variational parameters of shape
                (n_layers, n_params_per_layer) or
                (batch, n_layers, n_params_per_layer).
                If None, uses model's internal parameters.
            inputs (Optional[jnp.ndarray]): Input data of shape
                (batch_size, n_input_feat).
                If None, uses zero inputs.
            pulse_params (Optional[jnp.ndarray]): Pulse parameter scalers for
                pulse-mode gate execution.
            enc_params (Optional[jnp.ndarray]): Encoding parameters of shape
                (n_qubits, n_input_feat). If None, uses model's encoding parameters.
            data_reupload (Union[bool, List[List[bool]], List[List[List[bool]]]]):
                Data reupload configuration. If None, uses previously set reupload
                configuration.
            noise_params (Optional[Dict[str, Union[float, Dict[str, float]]]]):
                Noise configuration. If None, uses previously set noise parameters.
            execution_type (Optional[str]): Measurement type: "expval", "density",
                "probs", or "state". If None, uses current execution_type setting.
            force_mean (bool): If True, averages results over measurement qubits.
                Defaults to False.
            gate_mode (str): Gate execution backend, "unitary" or "pulse".
                Defaults to "unitary".

        Returns:
            jnp.ndarray: Circuit output with shape depending on execution_type:
                - "expval": (n_output_qubits,) or scalar
                - "density": (2^n_output, 2^n_output)
                - "probs": (2^n_output,) or (n_pairs, 2^pair_size)
                - "state": (2^n_qubits,)

        Raises:
            ValueError: If pulse_params provided without pulse gate_mode, or
                if noise_params provided with pulse gate_mode.
        """
        # set the parameters as object attributes
        if noise_params is not None:
            self.noise_params = noise_params
        if execution_type is not None:
            self.execution_type = execution_type
        self.gate_mode = gate_mode

        # consistency checks
        if pulse_params is not None and gate_mode != "pulse":
            raise ValueError(
                "pulse_params were provided but gate_mode is not 'pulse'. "
                "Either switch gate_mode='pulse' or do not pass pulse_params."
            )

        # TODO: add testing
        if data_reupload is not None:
            self.data_reupload = data_reupload

        params = self._params_validation(params)
        pulse_params = self._pulse_params_validation(pulse_params)
        inputs = self._inputs_validation(inputs)
        enc_params = self._enc_params_validation(enc_params)

        inputs, params, pulse_params = self._assimilate_batch(
            inputs,
            params,
            pulse_params,
        )

        # split to generate a sub_key, required for actual execution
        self.random_key, sub_key = safe_random_split(self.random_key)

        # Build measurement type & observables from execution_type / output_qubit
        meas_type, obs = self._build_obs()

        # Jaqsi auto-routes between statevector and density-matrix simulation
        # based on whether noise channels appear on the tape, so a single
        B = np.prod(self.eff_batch_shape)

        # kwargs are broadcast (not vmapped over)
        exec_kwargs = dict(
            noise_params=self.noise_params,
            gate_mode=self.gate_mode,
        )

        # Build a shot key from the random_key if shots are requested
        shot_key = None
        if self.shots is not None:
            # overwrite subkey and split shot_key
            sub_key, shot_key = safe_random_split(sub_key)

        if B > 1:
            # use random keys, derived from the subkey
            random_keys = safe_random_split(sub_key, num=B)

            in_axes = (
                0 if self.batch_shape[1] > 1 else None,  # params
                0 if self.batch_shape[0] > 1 else None,  # inputs
                0 if self.batch_shape[2] > 1 else None,  # pulse_params
                0,  # random_keys
                None,  # enc_params (broadcast, not batched)
            )

            result = self.script.execute(
                type=meas_type,
                obs=obs,
                args=(params, inputs, pulse_params, random_keys, enc_params),
                kwargs=exec_kwargs,
                in_axes=in_axes,
                shots=self.shots,
                key=shot_key,
            )
        else:
            # use the subkey directly
            result = self.script.execute(
                type=meas_type,
                obs=obs,
                args=(params, inputs, pulse_params, sub_key, enc_params),
                kwargs=exec_kwargs,
                shots=self.shots,
                key=shot_key,
            )

        result = self._postprocess_res(result)

        # --- Post-processing for partial-qubit measurements ---------------
        if self.execution_type == "density" and not self.all_qubit_measurement:
            result = js.partial_trace(result, self.n_qubits, self.output_qubit)

        if self.execution_type == "probs" and not self.all_qubit_measurement:
            if isinstance(self.output_qubit[0], (list, tuple)):
                # list of qubit groups - marginalize each independently
                result = jnp.stack(
                    [
                        js.marginalize_probs(result, self.n_qubits, list(group))
                        for group in self.output_qubit
                    ]
                )
            else:
                result = js.marginalize_probs(result, self.n_qubits, self.output_qubit)

        result = jnp.asarray(result)
        result = result.reshape((*self.eff_batch_shape, *self._result_shape)).squeeze()

        if (
            self.execution_type in ("expval", "probs")
            and force_mean
            and len(result.shape) > 0
            and self._result_shape[0] > 1
        ):
            result = result.mean(axis=-1)

        return result

all_qubit_measurement property #

Check if measurement is performed on all qubits.

batch_shape property #

Get the batch shape (B_I, B_P, B_R). If the model was not called before, it returns (1, 1, 1).

Returns:

Type Description
Tuple[int, ...]

Tuple[int, ...]: Tuple of (input_batch, param_batch, pulse_batch). Returns (1, 1, 1) if model has not been called yet.

data_reupload property writable #

Get the data reupload mask.

degree property writable #

Get the degree of the model.

eff_batch_shape property #

Get the effective batch shape after applying repeat_batch_axis mask.

Returns:

Type Description
Tuple[int, ...]

Tuple[int, ...]: Effective batch dimensions, excluding zeros.

enc_params property writable #

Get the encoding parameters used for input transformation.

execution_type property writable #

Gets the execution type of the model.

Returns:

Name Type Description
str str

The execution type, one of 'density', 'expval', or 'probs'.

frequencies property writable #

Get the frequencies of the model.

has_dru property #

Check if the model has data reupload.

noise_params property writable #

Gets the noise parameters of the model.

Returns:

Type Description
Optional[Dict[str, Union[float, Dict[str, float]]]]

Optional[Dict[str, float]]: A dictionary of

Optional[Dict[str, Union[float, Dict[str, float]]]]

noise parameters or None if not set.

output_qubit property writable #

Get the output qubit indices for measurement.

params property writable #

Get the variational parameters of the model.

pulse_params property writable #

Get the pulse parameters for pulse-mode gate execution.

shots property writable #

Gets the number of shots to use for the quantum device.

Returns:

Type Description
Optional[int]

Optional[int]: The number of shots.

__call__(params=None, inputs=None, pulse_params=None, enc_params=None, data_reupload=None, noise_params=None, execution_type=None, force_mean=False, gate_mode='unitary') #

Execute the quantum circuit (callable interface).

Provides a convenient callable interface for circuit execution, delegating to the _forward method.

Parameters:

Name Type Description Default
params Optional[ndarray]

Variational parameters of shape (n_layers, n_params_per_layer) or (batch, n_layers, n_params_per_layer). If None, uses model's internal parameters.

None
inputs Optional[ndarray]

Input data of shape (batch_size, n_input_feat). If None, uses zero inputs.

None
pulse_params Optional[ndarray]

Pulse parameter scalers for pulse-mode gate execution.

None
enc_params Optional[ndarray]

Encoding parameters of shape (n_qubits, n_input_feat). If None, uses model's encoding parameters.

None
data_reupload Union[bool, List[List[bool]], List[List[List[bool]]]]

Data reupload configuration. If None, uses previously set reupload configuration.

None
noise_params Optional[Dict[str, Union[float, Dict[str, float]]]]

Noise configuration. If None, uses previously set noise parameters.

None
execution_type Optional[str]

Measurement type: "expval", "density", "probs", or "state". If None, uses current execution_type setting.

None
force_mean bool

If True, averages results over measurement qubits. Defaults to False.

False
gate_mode str

Gate execution backend, "unitary" or "pulse". Defaults to "unitary".

'unitary'

Returns:

Type Description
ndarray

jnp.ndarray: Circuit output with shape depending on execution_type: - "expval": (n_output_qubits,) or scalar - "density": (2^n_output, 2^n_output) - "probs": (2^n_output,) or (n_pairs, 2^pair_size) - "state": (2^n_qubits,)

Source code in qml_essentials/model.py
def __call__(
    self,
    params: Optional[jnp.ndarray] = None,
    inputs: Optional[jnp.ndarray] = None,
    pulse_params: Optional[jnp.ndarray] = None,
    enc_params: Optional[jnp.ndarray] = None,
    data_reupload: Union[bool, List[List[bool]], List[List[List[bool]]]] = None,
    noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
    execution_type: Optional[str] = None,
    force_mean: bool = False,
    gate_mode: str = "unitary",
) -> jnp.ndarray:
    """
    Execute the quantum circuit (callable interface).

    Provides a convenient callable interface for circuit execution,
    delegating to the _forward method.

    Args:
        params (Optional[jnp.ndarray]): Variational parameters of shape
            (n_layers, n_params_per_layer) or (batch, n_layers, n_params_per_layer).
            If None, uses model's internal parameters.
        inputs (Optional[jnp.ndarray]): Input data of shape
            (batch_size, n_input_feat). If None, uses zero inputs.
        pulse_params (Optional[jnp.ndarray]): Pulse parameter scalers for
            pulse-mode gate execution.
        enc_params (Optional[jnp.ndarray]): Encoding parameters of shape
            (n_qubits, n_input_feat). If None, uses model's encoding parameters.
        data_reupload (Union[bool, List[List[bool]], List[List[List[bool]]]]):
            Data reupload configuration. If None, uses previously set reupload
            configuration.
        noise_params (Optional[Dict[str, Union[float, Dict[str, float]]]]):
            Noise configuration. If None, uses previously set noise parameters.
        execution_type (Optional[str]): Measurement type: "expval", "density",
            "probs", or "state". If None, uses current execution_type setting.
        force_mean (bool): If True, averages results over measurement qubits.
            Defaults to False.
        gate_mode (str): Gate execution backend, "unitary" or "pulse".
            Defaults to "unitary".

    Returns:
        jnp.ndarray: Circuit output with shape depending on execution_type:
            - "expval": (n_output_qubits,) or scalar
            - "density": (2^n_output, 2^n_output)
            - "probs": (2^n_output,) or (n_pairs, 2^pair_size)
            - "state": (2^n_qubits,)
    """
    # Call forward method which handles the actual caching etc.
    return self._forward(
        params=params,
        inputs=inputs,
        pulse_params=pulse_params,
        enc_params=enc_params,
        data_reupload=data_reupload,
        noise_params=noise_params,
        execution_type=execution_type,
        force_mean=force_mean,
        gate_mode=gate_mode,
    )

__init__(n_qubits, n_layers, circuit_type='No_Ansatz', data_reupload=True, state_preparation=None, encoding=Gates.RX, trainable_frequencies=False, initialization='random', initialization_domain=[0, 2 * jnp.pi], output_qubit=-1, shots=None, random_seed=1000, remove_zero_encoding=True, repeat_batch_axis=[True, True, True], pulse_shape='gaussian') #

Initialize the quantum circuit model. Parameters will have the shape [impl_n_layers, parameters_per_layer] where impl_n_layers is the number of layers provided and added by one depending if data_reupload is True and parameters_per_layer is given by the chosen ansatz.

The model is initialized with the following parameters as defaults: - noise_params: None - execution_type: "expval" - shots: None

Parameters:

Name Type Description Default
n_qubits int

The number of qubits in the circuit.

required
n_layers int

The number of layers in the circuit.

required
circuit_type (str, Circuit)

The type of quantum circuit to use. If None, defaults to "no_ansatz".

'No_Ansatz'
data_reupload Union[bool, List[bool], List[List[bool]]]

Whether to reupload data to the quantum device on each layer and qubit. Detailed re-uploading instructions can be given as a list/array of 0/False and 1/True with shape (n_qubits, n_layers) to specify where to upload the data. Defaults to True for applying data re-uploading to the full circuit.

True
encoding Union[str, Callable, List[str], List[Callable]]

The unitary to use for encoding the input data. Can be a string (e.g. "RX") or a callable (e.g. op.RX). Defaults to op.RX. If input is multidimensional it is assumed to be a list of unitaries or a list of strings.

RX
trainable_frequencies bool

Sets trainable encoding parameters for trainable frequencies. Defaults to False.

False
initialization str

The strategy to initialize the parameters. Can be "random", "zeros", "zero-controlled", "pi", or "pi-controlled". Defaults to "random".

'random'
output_qubit (List[int], int)

The index of the output qubit (or qubits). When set to -1 all qubits are measured, or a global measurement is conducted, depending on the execution type.

-1
shots Optional[int]

The number of shots to use for the quantum device. Defaults to None.

None
random_seed int

seed for the random number generator in initialization is "random" and for random noise parameters. Defaults to 1000.

1000
remove_zero_encoding bool

whether to remove the zero encoding from the circuit. Defaults to True.

True
repeat_batch_axis List[bool]

Each boolean in the array determines over which axes to parallelise computation. The axes correspond to [inputs, params, pulse_params]. Defaults to [True, True, True], meaning that batching is enabled over all axes.

[True, True, True]
pulse_shape str

Pulse envelope shape for pulse-level simulation. One of PulseEnvelope.available(). Defaults to "gaussian".

'gaussian'

Returns:

Type Description
None

None

Source code in qml_essentials/model.py
def __init__(
    self,
    n_qubits: int,
    n_layers: int,
    circuit_type: Union[str, Circuit] = "No_Ansatz",
    data_reupload: Union[bool, List[List[bool]], List[List[List[bool]]]] = True,
    state_preparation: Union[
        str, Callable, List[Union[str, Callable]], None
    ] = None,
    encoding: Union[Encoding, str, Callable, List[Union[str, Callable]]] = Gates.RX,
    trainable_frequencies: bool = False,
    initialization: str = "random",
    initialization_domain: List[float] = [0, 2 * jnp.pi],
    output_qubit: Union[List[int], int] = -1,
    shots: Optional[int] = None,
    random_seed: int = 1000,
    remove_zero_encoding: bool = True,
    repeat_batch_axis: List[bool] = [True, True, True],
    pulse_shape: str = "gaussian",
) -> None:
    """
    Initialize the quantum circuit model.
    Parameters will have the shape [impl_n_layers, parameters_per_layer]
    where impl_n_layers is the number of layers provided and added by one
    depending if data_reupload is True and parameters_per_layer is given by
    the chosen ansatz.

    The model is initialized with the following parameters as defaults:
    - noise_params: None
    - execution_type: "expval"
    - shots: None

    Args:
        n_qubits (int): The number of qubits in the circuit.
        n_layers (int): The number of layers in the circuit.
        circuit_type (str, Circuit): The type of quantum circuit to use.
            If None, defaults to "no_ansatz".
        data_reupload (Union[bool, List[bool], List[List[bool]]], optional):
            Whether to reupload data to the quantum device on each
            layer and qubit. Detailed re-uploading instructions can be given
            as a list/array of 0/False and 1/True with shape (n_qubits,
            n_layers) to specify where to upload the data. Defaults to True
            for applying data re-uploading to the full circuit.
        encoding (Union[str, Callable, List[str], List[Callable]], optional):
            The unitary to use for encoding the input data. Can be a string
            (e.g. "RX") or a callable (e.g. op.RX). Defaults to op.RX.
            If input is multidimensional it is assumed to be a list of
            unitaries or a list of strings.
        trainable_frequencies (bool, optional):
            Sets trainable encoding parameters for trainable frequencies.
            Defaults to False.
        initialization (str, optional): The strategy to initialize the parameters.
            Can be "random", "zeros", "zero-controlled", "pi", or "pi-controlled".
            Defaults to "random".
        output_qubit (List[int], int, optional): The index of the output
            qubit (or qubits). When set to -1 all qubits are measured, or a
            global measurement is conducted, depending on the execution
            type.
        shots (Optional[int], optional): The number of shots to use for
            the quantum device. Defaults to None.
        random_seed (int, optional): seed for the random number generator
            in initialization is "random" and for random noise parameters.
            Defaults to 1000.
        remove_zero_encoding (bool, optional): whether to
            remove the zero encoding from the circuit. Defaults to True.
        repeat_batch_axis (List[bool], optional): Each boolean in the array
            determines over which axes to parallelise computation. The axes
            correspond to [inputs, params, pulse_params]. Defaults to
            [True, True, True], meaning that batching is enabled over all
            axes.
        pulse_shape (str, optional): Pulse envelope shape for pulse-level
            simulation. One of ``PulseEnvelope.available()``.
            Defaults to ``"gaussian"``.

    Returns:
        None
    """
    # Initialize default parameters needed for circuit evaluation
    self.n_qubits: int = n_qubits
    self.output_qubit: Union[List[int], int] = output_qubit
    self.n_layers: int = n_layers
    self.noise_params: Optional[Dict[str, Union[float, Dict[str, float]]]] = None
    self.shots = shots
    self.remove_zero_encoding = remove_zero_encoding
    self.trainable_frequencies: bool = trainable_frequencies
    self.execution_type: str = "expval"
    self.repeat_batch_axis: List[bool] = repeat_batch_axis

    # --- Pulse envelope ---
    pinfo.set_envelope(pulse_shape)

    # --- State Preparation ---
    try:
        self._sp = Gates.parse_gates(state_preparation, Gates)
    except ValueError as e:
        raise ValueError(f"Error parsing encodings: {e}")

    # prepare corresponding pulse parameters (always optimized pulses)
    self.sp_pulse_params = []
    for sp in self._sp:
        sp_name = sp.__name__ if hasattr(sp, "__name__") else str(sp)

        if pinfo.gate_by_name(sp_name) is not None:
            self.sp_pulse_params.append(pinfo.gate_by_name(sp_name).params)
        else:
            # gate has no pulse parametrization
            self.sp_pulse_params.append(None)

    # --- Encoding ---
    if isinstance(encoding, Encoding):
        # user wants custom strategy? do it!
        self._enc = encoding
    else:
        # use hammming encoding by default
        self._enc = Encoding("hamming", encoding)

    if self._enc.is_golomb:
        self._enc._n_qubits = n_qubits

    # Number of possible inputs
    self.n_input_feat = len(self._enc)
    log.debug(f"Number of input features: {self.n_input_feat}")

    # Trainable frequencies, default initialization as in arXiv:2309.03279v2
    self.enc_params = jnp.ones((self.n_layers, self.n_qubits, self.n_input_feat))

    self._zero_inputs = False

    # --- Data-Reuploading ---

    # Keep as NumPy array (not JAX) so that ``if data_reupload[q, idx]``
    # in _iec remains a concrete Python bool even under jax.jit tracing.
    # note that setting this will also update self.degree and self.frequencies
    # and in consequence also self.has_dru
    self.data_reupload = data_reupload

    # check for the highest degree among all input dimensions
    if self.has_dru:
        impl_n_layers: int = n_layers + 1  # we need L+1 according to Schuld et al.
    else:
        impl_n_layers = n_layers
    log.info(f"Number of implicit layers: {impl_n_layers}.")

    # --- Ansatz ---
    # only weak check for str. We trust the user to provide sth useful
    if isinstance(circuit_type, str):
        self.pqc: Callable[[Optional[jnp.ndarray], int], int] = getattr(
            Ansaetze, circuit_type or "No_Ansatz"
        )()
    else:
        self.pqc = circuit_type()
    log.info(f"Using Ansatz {circuit_type}.")

    # calculate the shape of the parameter vector here, we will re-use this in init.
    params_per_layer = self.pqc.n_params_per_layer(self.n_qubits)
    self._params_shape: Tuple[int, int] = (impl_n_layers, params_per_layer)
    log.info(f"Parameters per layer: {params_per_layer}")

    pulse_params_per_layer = self.pqc.n_pulse_params_per_layer(self.n_qubits)
    self._pulse_params_shape: Tuple[int, int] = (
        impl_n_layers,
        pulse_params_per_layer,
    )

    # intialize to None as we can't know this yet
    self._batch_shape = None

    # this will also be re-used in the init method,
    # however, only if nothing is provided
    self._inialization_strategy = initialization
    self._initialization_domain = initialization_domain

    # ..here! where we only require a JAX random key
    self.random_key = self.initialize_params(random.key(random_seed))

    # Initializing pulse params
    self.pulse_params: jnp.ndarray = jnp.ones((1, *self._pulse_params_shape))

    log.info(f"Initialized pulse parameters with shape {self.pulse_params.shape}.")

    # Initialise the jaqsi Script that wraps _variational.
    # No device selection needed - jaqsi auto-routes between statevector
    # and density-matrix simulation based on whether noise channels are
    # present on the tape.
    self.script = js.Script(f=self._variational, n_qubits=self.n_qubits)

__repr__() #

Return text representation of the quantum circuit model.

Source code in qml_essentials/model.py
def __repr__(self) -> str:
    """Return text representation of the quantum circuit model."""
    return self.draw(figure="text")

__str__() #

Return string representation of the quantum circuit model.

Source code in qml_essentials/model.py
def __str__(self) -> str:
    """Return string representation of the quantum circuit model."""
    return self.draw(figure="text")

draw(inputs=None, figure='text', **kwargs) #

Visualize the quantum circuit.

Records the circuit tape (without noise) and renders the gate sequence using the requested backend.

Parameters:

Name Type Description Default
inputs Optional[ndarray]

Input data for the circuit. If None, default zero inputs are used.

None
figure str

Rendering backend. One of:

  • "text" - ASCII art (returned as a str).
  • "mpl" - Matplotlib figure (returns (fig, ax)).
  • "tikz" - LaTeX/TikZ quantikz code (returns a :class:TikzFigure).
  • "pulse" - Pulse schedule (returns (fig, axes)). Only meaningful for pulse-mode models.
'text'
**kwargs Any

Extra options forwarded to the drawing backend (e.g. gate_values=True).

{}

Returns:

Type Description
Union[str, Any]

Depends on figure:

Union[str, Any]
  • "text" -> str
Union[str, Any]
  • "mpl" -> (matplotlib.figure.Figure, matplotlib.axes.Axes)
Union[str, Any]
  • "tikz" -> :class:TikzFigure

Raises:

Type Description
ValueError

If figure is not one of the supported modes.

Source code in qml_essentials/model.py
def draw(
    self,
    inputs: Optional[jnp.ndarray] = None,
    figure: str = "text",
    **kwargs: Any,
) -> Union[str, Any]:
    """Visualize the quantum circuit.

    Records the circuit tape (without noise) and renders the gate
    sequence using the requested backend.

    Args:
        inputs (Optional[jnp.ndarray]): Input data for the circuit.
            If ``None``, default zero inputs are used.
        figure (str): Rendering backend.  One of:

            * ``"text"``  - ASCII art (returned as a ``str``).
            * ``"mpl"``   - Matplotlib figure (returns ``(fig, ax)``).
            * ``"tikz"``  - LaTeX/TikZ ``quantikz`` code (returns a
              :class:`TikzFigure`).
            * ``"pulse"`` - Pulse schedule (returns ``(fig, axes)``).
              Only meaningful for pulse-mode models.

        **kwargs: Extra options forwarded to the drawing backend
            (e.g. ``gate_values=True``).

    Returns:
        Depends on figure:

        * ``"text"``  -> ``str``
        * ``"mpl"``   -> ``(matplotlib.figure.Figure, matplotlib.axes.Axes)``
        * ``"tikz"``  -> :class:`TikzFigure`

    Raises:
        ValueError: If figure is not one of the supported modes.
    """
    inputs = self._inputs_validation(inputs)
    params = self.params[0] if self.params.ndim == 3 else self.params
    inp = inputs[0] if inputs.ndim == 2 else inputs

    if figure == "pulse":
        return self.draw_pulse(inputs=inputs, **kwargs)

    # Record without noise to get a clean circuit
    saved_noise = self._noise_params
    self._noise_params = None

    draw_script = js.Script(f=self._variational, n_qubits=self.n_qubits)
    result = draw_script.draw(
        figure=figure,
        args=(params, inp),
        kwargs={"noise_params": None},
        **kwargs,
    )

    self._noise_params = saved_noise
    return result

draw_pulse(inputs=None, **kwargs) #

Visualize the pulse schedule for the circuit.

Records the circuit in pulse mode and collects PulseEvents automatically via the pulse-event tape, then renders them.

Parameters:

Name Type Description Default
inputs Optional[ndarray]

Input data. If None, default zero inputs are used.

None
**kwargs Any

Forwarded to :func:~qml_essentials.drawing.draw_pulse_schedule (e.g. show_carrier=True, n_samples=300).

{}

Returns:

Type Description
Any

(fig, axes) — Matplotlib Figure and array of Axes.

Source code in qml_essentials/model.py
def draw_pulse(
    self,
    inputs: Optional[jnp.ndarray] = None,
    **kwargs: Any,
) -> Any:
    """Visualize the pulse schedule for the circuit.

    Records the circuit in pulse mode and collects PulseEvents
    automatically via the pulse-event tape, then renders them.

    Args:
        inputs: Input data.  If ``None``, default zero inputs are used.
        **kwargs: Forwarded to
            :func:`~qml_essentials.drawing.draw_pulse_schedule`
            (e.g. ``show_carrier=True``, ``n_samples=300``).

    Returns:
        ``(fig, axes)`` — Matplotlib Figure and array of Axes.
    """
    inputs = self._inputs_validation(inputs)
    params = self.params[0] if self.params.ndim == 3 else self.params
    inp = inputs[0] if inputs.ndim == 2 else inputs

    draw_script = js.Script(f=self._variational, n_qubits=self.n_qubits)
    return draw_script.draw(
        figure="pulse",
        args=(params, inp),
        kwargs={
            "gate_mode": "pulse",
            "noise_params": None,
        },
        **kwargs,
    )

exact_spectrum(method='tree') #

Compute the exact per-feature Fourier spectrum via the FourierTree.

Unlike :attr:frequencies -- a naive per-feature estimate derived purely from the encoding, which can overestimate the spectrum (some coefficients are constrained to zero for all parameters) -- this builds the analytical Fourier tree (Nemkov et al.) and returns, for each input feature, the integer frequencies whose Fourier coefficient is not identically zero. The result is always a subset of :attr:frequencies.

The support is derived purely symbolically (no parameter sampling): see :meth:~qml_essentials.coefficients.FourierTree.get_exact_support. With method="tree" (default), frequencies whose contributions cancel identically across tree paths (e.g. two consecutive encodings combining into a single rotation) are excluded exactly; this enumerates the explicit tree, which can be infeasible for deep entangling circuits. With method="dp", a merged-state dynamic program derives the support without enumerating paths, which scales to deep circuits (single input feature only) at the cost of not detecting identical cross-path cancellations.

Requires a Clifford + Pauli-rotation ansatz (see :class:~qml_essentials.pauli.PauliCircuit); other gate sets raise NotImplementedError during tree construction.

Parameters:

Name Type Description Default
method str

"tree" (fully exact) or "dp" (scalable).

'tree'

Returns:

Type Description
ndarray

Tuple[np.ndarray, ...]: One sorted integer frequency array per input

...

feature (same layout as :attr:frequencies).

Source code in qml_essentials/model.py
def exact_spectrum(self, method: str = "tree") -> Tuple[np.ndarray, ...]:
    """Compute the exact per-feature Fourier spectrum via the FourierTree.

    Unlike :attr:`frequencies` -- a naive per-feature estimate derived purely
    from the encoding, which can *overestimate* the spectrum (some
    coefficients are constrained to zero for all parameters) -- this builds
    the analytical Fourier tree (Nemkov et al.) and returns, for each input
    feature, the integer frequencies whose Fourier coefficient is not
    identically zero.  The result is always a subset of :attr:`frequencies`.

    The support is derived purely symbolically (no parameter sampling): see
    :meth:`~qml_essentials.coefficients.FourierTree.get_exact_support`.
    With ``method="tree"`` (default), frequencies whose contributions cancel
    identically across tree paths (e.g. two consecutive encodings combining
    into a single rotation) are excluded exactly; this enumerates the
    explicit tree, which can be infeasible for deep entangling circuits.
    With ``method="dp"``, a merged-state dynamic program derives the support
    without enumerating paths, which scales to deep circuits (single input
    feature only) at the cost of not detecting identical cross-path
    cancellations.

    Requires a Clifford + Pauli-rotation ansatz (see
    :class:`~qml_essentials.pauli.PauliCircuit`); other gate sets raise
    ``NotImplementedError`` during tree construction.

    Args:
        method (str): ``"tree"`` (fully exact) or ``"dp"`` (scalable).

    Returns:
        Tuple[np.ndarray, ...]: One sorted integer frequency array per input
        feature (same layout as :attr:`frequencies`).
    """
    from qml_essentials.coefficients import FourierTree  # avoid circular imp.

    tree = FourierTree(self)

    # Position of each model feature within the tree's frequency vectors.
    feature_pos = {feat: i for i, feat in enumerate(tree.features)}

    # Union of the symbolic supports over all observables (roots).
    support = set()
    for freqs in tree.get_exact_support(method=method):
        farr = np.asarray(freqs)
        for k in range(farr.shape[0]):
            key = (
                (int(farr[k]),)
                if farr.ndim == 1
                else tuple(int(v) for v in farr[k])
            )
            support.add(key)

    spectrum = []
    for feat in range(self.n_input_feat):
        if support and feat in feature_pos:
            pos = feature_pos[feat]
            vals = sorted({k[pos] for k in support})
        else:
            vals = [0]
        spectrum.append(np.array(vals, dtype=int))
    return tuple(spectrum)

initialize_params(random_key=None, repeat=1, initialization=None, initialization_domain=None) #

Initialize the variational parameters of the model.

Parameters:

Name Type Description Default
random_key Optional[PRNGKey]

JAX random key for initialization. If None, uses the model's internal random key.

None
repeat int

Number of parameter sets to create (batch dimension). Defaults to 1.

1
initialization Optional[str]

Strategy for parameter initialization. Options: "random", "zeros", "pi", "zero-controlled", "pi-controlled". If None, uses the strategy specified in the constructor.

None
initialization_domain Optional[List[float]]

Domain [min, max] for random initialization. If None, uses the domain from constructor.

None

Returns:

Type Description
PRNGKey

random.PRNGKey: Updated random key after initialization.

Raises:

Type Description
Exception

If an invalid initialization method is specified.

Source code in qml_essentials/model.py
def initialize_params(
    self,
    random_key: Optional[random.PRNGKey] = None,
    repeat: int = 1,
    initialization: Optional[str] = None,
    initialization_domain: Optional[List[float]] = None,
) -> random.PRNGKey:
    """
    Initialize the variational parameters of the model.

    Args:
        random_key (Optional[random.PRNGKey]): JAX random key for initialization.
            If None, uses the model's internal random key.
        repeat (int): Number of parameter sets to create (batch dimension).
            Defaults to 1.
        initialization (Optional[str]): Strategy for parameter initialization.
            Options: "random", "zeros", "pi", "zero-controlled", "pi-controlled".
            If None, uses the strategy specified in the constructor.
        initialization_domain (Optional[List[float]]): Domain [min, max] for
            random initialization. If None, uses the domain from constructor.

    Returns:
        random.PRNGKey: Updated random key after initialization.

    Raises:
        Exception: If an invalid initialization method is specified.
    """
    # Initializing params
    params_shape = (repeat, *self._params_shape)

    # use existing strategy if not specified
    initialization = initialization or self._inialization_strategy
    initialization_domain = initialization_domain or self._initialization_domain

    random_key, sub_key = safe_random_split(
        random_key if random_key is not None else self.random_key
    )

    def set_control_params(params: jnp.ndarray, value: float) -> jnp.ndarray:
        indices = self.pqc.get_control_indices(self.n_qubits)
        if indices is None:
            warnings.warn(
                f"Specified {initialization} but circuit\
                does not contain controlled rotation gates.\
                Parameters are intialized randomly.",
                UserWarning,
            )
        else:
            np_params = np.array(params)
            np_params[:, :, indices[0] : indices[1] : indices[2]] = (
                np.ones_like(params[:, :, indices[0] : indices[1] : indices[2]])
                * value
            )
            params = jnp.array(np_params)
        return params

    if initialization == "random":
        self.params: jnp.ndarray = random.uniform(
            sub_key,
            params_shape,
            minval=initialization_domain[0],
            maxval=initialization_domain[1],
        )
    elif initialization == "zeros":
        self.params: jnp.ndarray = jnp.zeros(params_shape)
    elif initialization == "pi":
        self.params: jnp.ndarray = jnp.ones(params_shape) * jnp.pi
    elif initialization == "zero-controlled":
        self.params: jnp.ndarray = random.uniform(
            sub_key,
            params_shape,
            minval=initialization_domain[0],
            maxval=initialization_domain[1],
        )
        self.params = set_control_params(self.params, 0)
    elif initialization == "pi-controlled":
        self.params: jnp.ndarray = random.uniform(
            sub_key,
            params_shape,
            minval=initialization_domain[0],
            maxval=initialization_domain[1],
        )
        self.params = set_control_params(self.params, jnp.pi)
    else:
        raise Exception("Invalid initialization method")

    log.info(
        f"Initialized parameters with shape {self.params.shape}\
        using strategy {initialization}."
    )

    return random_key

transform_input(inputs, enc_params) #

Transform input data by scaling with encoding parameters.

Implements the input transformation as described in arXiv:2309.03279v2, where inputs are linearly scaled by encoding parameters before being used in the quantum circuit.

Parameters:

Name Type Description Default
inputs ndarray

Input data point of shape (n_input_feat,) or (batch_size, n_input_feat).

required
enc_params ndarray

Encoding weight scalar or vector used to scale the input.

required

Returns:

Type Description
ndarray

jnp.ndarray: Transformed input, element-wise product of inputs and enc_params.

Source code in qml_essentials/model.py
def transform_input(
    self, inputs: jnp.ndarray, enc_params: jnp.ndarray
) -> jnp.ndarray:
    """
    Transform input data by scaling with encoding parameters.

    Implements the input transformation as described in arXiv:2309.03279v2,
    where inputs are linearly scaled by encoding parameters before being
    used in the quantum circuit.

    Args:
        inputs (jnp.ndarray): Input data point of shape (n_input_feat,) or
            (batch_size, n_input_feat).
        enc_params (jnp.ndarray): Encoding weight scalar or vector used to
            scale the input.

    Returns:
        jnp.ndarray: Transformed input, element-wise product of inputs
            and enc_params.
    """
    return inputs * enc_params

Entanglement#

from qml_essentials.entanglement import Entanglement
Source code in qml_essentials/entanglement.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
class Entanglement:
    @classmethod
    def meyer_wallach(
        cls,
        model: Model,
        n_samples: Optional[int | None],
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Calculates the entangling capacity of a given quantum circuit
        using Meyer-Wallach measure.

        Args:
            model (Model): The quantum circuit model.
            n_samples (Optional[int]): Number of samples per qubit.
                If None or < 0, the current parameters of the model are used.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: Entangling capacity of the given circuit, guaranteed
                to be between 0.0 and 1.0.
        """
        if "noise_params" in kwargs:
            log.warning(
                "Meyer-Wallach measure not suitable for noisy circuits. "
                "Consider 'concentratable entanglement' instead."
            )

        if scale:
            n_samples = jnp.power(2, model.n_qubits) * n_samples

        if n_samples is not None and n_samples > 0:
            random_key = model.initialize_params(random_key, repeat=n_samples)

        # implicitly set input to none in case it's not needed
        kwargs.setdefault("inputs", None)
        # explicitly set execution type because everything else won't work
        rhos = model(execution_type="density", **kwargs).reshape(
            -1, 2**model.n_qubits, 2**model.n_qubits
        )

        ent = cls._compute_meyer_wallach_meas(rhos, model.n_qubits)

        log.debug(f"Variance of measure: {ent.var()}")

        return ent.mean()

    @classmethod
    def _compute_meyer_wallach_meas(
        cls, rhos: jnp.ndarray, n_qubits: int
    ) -> jnp.ndarray:
        """
        Computes the Meyer-Wallach entangling capability measure for a given
        set of density matrices.

        Args:
            rhos (jnp.ndarray): Density matrices of the sample quantum states.
                The shape is (B_s, 2^n, 2^n), where B_s is the number of samples
                (batch) and n the number of qubits
            n_qubits (int): The number of qubits

                    Returns:
            jnp.ndarray: Entangling capability for each sample, array with
                shape (B_s,)
        """
        qb = list(range(n_qubits))

        def _f(rhos):
            entropy = 0
            for j in range(n_qubits):
                # Formula 6 in https://doi.org/10.48550/arXiv.quant-ph/0305094
                # Trace out qubit j, keep all others
                keep = qb[:j] + qb[j + 1 :]
                density = js.partial_trace(rhos, n_qubits, keep)
                # only real values, because imaginary part will be separate
                # in all following calculations anyway
                # entropy should be 1/2 <= entropy <= 1
                entropy += jnp.trace((density @ density).real, axis1=-2, axis2=-1)

            # inverse averaged entropy and scale to [0, 1]
            return 2 * (1 - entropy / n_qubits)

        return jax.vmap(_f)(rhos)

    @classmethod
    def bell_measurements(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Compute the Bell measurement for a given model.

        Constructs a ``2 * n_qubits`` circuit that prepares two copies of
        the model state (on disjoint qubit registers), applies CNOTs and
        Hadamards, and measures probabilities on the first register.

        Args:
            model (Model): The quantum circuit model.
            n_samples (int): The number of samples to compute the measure for.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples
                according to the number of qubits.
            **kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: The Bell measurement value.
        """
        if "noise_params" in kwargs:
            log.warning(
                "Bell Measurements not suitable for noisy circuits. "
                "Consider 'concentratable entanglement' instead."
            )

        if scale:
            n_samples = jnp.power(2, model.n_qubits) * n_samples

        n = model.n_qubits

        def _bell_circuit(params, inputs, pulse_params=None, random_key=None, **kw):
            """Bell measurement circuit on 2*n qubits."""
            from qml_essentials.tape import copy_to_tape

            def vari():
                model._variational(
                    params,
                    inputs,
                    pulse_params=pulse_params,
                    random_key=random_key,
                    **kw,
                )

            # First copy on wires 0..n-1
            vari()
            # Second copy on wires n..2n-1
            copy_to_tape(vari, offset=n)

            # Bell measurement: CNOT + H
            for q in range(n):
                op.CX(wires=[q, q + n])
                op.H(wires=q)

        bell_script = js.Script(f=_bell_circuit, n_qubits=2 * n)

        if n_samples is not None and n_samples > 0:
            random_key = model.initialize_params(random_key, repeat=n_samples)
            params = model.params
        else:
            if len(model.params.shape) <= 2:
                params = model.params.reshape(1, *model.params.shape)
            else:
                log.info(f"Using sample size of model params: {model.params.shape[0]}")
                params = model.params

        n_samples = params.shape[0]
        inputs = model._inputs_validation(kwargs.get("inputs", None))

        # Execute: vmap over batch dimension of params (axis 0)
        if n_samples > 1:
            from qml_essentials.utils import safe_random_split

            random_keys = safe_random_split(random_key, num=n_samples)
            result = bell_script.execute(
                type="probs",
                args=(params, inputs, model.pulse_params, random_keys),
                kwargs=kwargs,
                in_axes=(0, None, None, 0),
            )
        else:
            result = bell_script.execute(
                type="probs",
                args=(params, inputs, model.pulse_params, random_key),
                kwargs=kwargs,
            )

        # Marginalize: for each qubit q, keep wires [q, q+n] from the 2n-qubit probs
        # The last probability in each pair gives P(|11⟩) for that qubit pair
        per_qubit = []
        for q in range(n):
            marg = js.marginalize_probs(result, 2 * n, [q, q + n])
            per_qubit.append(marg)
        # per_qubit[q] has shape (n_samples, 4) or (4,)
        exp = jnp.stack(per_qubit, axis=-2)  # (..., n, 4)
        exp = 1 - 2 * exp[..., -1]  # (..., n)

        if not jnp.isclose(jnp.sum(exp.imag), 0, atol=1e-6):
            log.warning("Imaginary part of probabilities detected")
            exp = jnp.abs(exp)

        measure = 2 * (1 - exp.mean(axis=0))
        entangling_capability = min(max(float(measure.mean()), 0.0), 1.0)
        log.debug(f"Variance of measure: {measure.var()}")

        return entangling_capability

    @classmethod
    def relative_entropy(
        cls,
        model: Model,
        n_samples: int,
        n_sigmas: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Calculates the relative entropy of entanglement of a given quantum
        circuit. This measure is also applicable to mixed state, albeit it
        might me not fully accurate in this simplified case.

        As the relative entropy is generally defined as the smallest relative
        entropy from the state in question to the set of separable states.
        However, as computing the nearest separable state is NP-hard, we select
        n_sigmas of random separable states to compute the distance to, which
        is not necessarily the nearest. Thus, this measure of entanglement
        presents an upper limit of entanglement.

        As the relative entropy is not necessarily between zero and one, this
        function also normalises by the relative entroy to the GHZ state.

        Args:
            model (Model): The quantum circuit model.
            n_samples (int): Number of samples per qubit.
                If <= 0, the current parameters of the model are used.
            n_sigmas (int): Number of random separable pure states to compare against.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: Entangling capacity of the given circuit, guaranteed
                to be between 0.0 and 1.0.
        """
        dim = jnp.power(2, model.n_qubits)
        if scale:
            n_samples = dim * n_samples
            n_sigmas = dim * n_sigmas

        if random_key is None:
            random_key = model.random_key

        # Random separable states
        log_sigmas = sample_random_separable_states(
            model.n_qubits, n_samples=n_sigmas, random_key=random_key, take_log=True
        )

        random_key, _ = jax.random.split(random_key)

        if n_samples is not None and n_samples > 0:
            model.initialize_params(random_key, repeat=n_samples)
        else:
            if len(model.params.shape) <= 2:
                model.params = model.params.reshape(1, *model.params.shape)
            else:
                log.info(f"Using sample size of model params: {model.params.shape[0]}")

        rhos, log_rhos = cls._compute_log_density(model, **kwargs)

        rel_entropies = jnp.zeros((n_sigmas, model.params.shape[0]))

        for i, log_sigma in enumerate(log_sigmas):
            rel_entropies = rel_entropies.at[i].set(
                cls._compute_rel_entropies(rhos, log_rhos, log_sigma)
            )

        # Entropy of GHZ states should be maximal
        ghz_model = Model(model.n_qubits, 1, "GHZ", data_reupload=False)
        rho_ghz, log_rho_ghz = cls._compute_log_density(ghz_model, **kwargs)
        ghz_entropies = cls._compute_rel_entropies(rho_ghz, log_rho_ghz, log_sigmas)

        normalised_entropies = rel_entropies / ghz_entropies

        # Average all iterated states
        entangling_capability = normalised_entropies.T.min(axis=1)
        log.debug(f"Variance of measure: {entangling_capability.var()}")

        return entangling_capability.mean()

    @classmethod
    def _compute_log_density(
        cls, model: Model, **kwargs
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Obtains the density matrix of a model and computes its logarithm.

        Args:
            model (Model): The model for which to compute the density matrix.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]:
                - jnp.ndarray: density matrix.
                - jnp.ndarray: logarithm of the density matrix.
        """
        # implicitly set input to none in case it's not needed
        kwargs.setdefault("inputs", None)
        # explicitly set execution type because everything else won't work
        rho = model(execution_type="density", **kwargs)
        rho = rho.reshape(-1, 2**model.n_qubits, 2**model.n_qubits)
        log_rho = logm_v(rho) / jnp.log(2)
        return rho, log_rho

    @classmethod
    def _compute_rel_entropies(
        cls,
        rhos: jnp.ndarray,
        log_rhos: jnp.ndarray,
        log_sigmas: jnp.ndarray,
    ) -> jnp.ndarray:
        """
        Compute the relative entropy for a given model.

        Args:
            rhos (jnp.ndarray): Density matrix result of the circuit, has shape
                (R, 2^n, 2^n), with the batch size R and number of qubits n
            log_rhos (jnp.ndarray): Corresponding logarithm of the density
                matrix, has shape (R, 2^n, 2^n).
            log_sigmas (jnp.ndarray): Density matrix of next separable state,
                has shape (2^n, 2^n) if it's a single sigma or (S, 2^n, 2^n),
                with the batch size S (number of sigmas).

        Returns:
            jnp.ndarray: Relative Entropy for each sample
        """
        n_rhos = rhos.shape[0]
        if len(log_sigmas.shape) == 3:
            n_sigmas = log_sigmas.shape[0]
            rhos = jnp.tile(rhos, (n_sigmas, 1, 1))
            log_rhos = jnp.tile(log_rhos, (n_sigmas, 1, 1))
            einsum_subscript = "ij,jk->ik"
        else:
            n_sigmas = 1
            log_sigmas = log_sigmas[jnp.newaxis, ...].repeat(n_rhos, axis=0)

        einsum_subscript = "ij,jk->ik"

        def _f(rhos, log_rhos, log_sigmas):
            prod = jnp.einsum(einsum_subscript, rhos, log_rhos - log_sigmas)
            rel_entropies = jnp.abs(jnp.trace(prod, axis1=-2, axis2=-1))
            return rel_entropies

        rel_entropies = jax.vmap(_f, in_axes=(0, 0, 0))(rhos, log_rhos, log_sigmas)

        if n_sigmas > 1:
            rel_entropies = rel_entropies.reshape(n_sigmas, n_rhos)
        return rel_entropies

    @classmethod
    def entanglement_of_formation(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        always_decompose: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        This function implements the entanglement of formation for mixed
        quantum systems.
        In that a mixed state gets decomposed into pure states with respective
        probabilities using the eigendecomposition of the density matrix.
        Then, the Meyer-Wallach measure is computed for each pure state,
        weighted by the eigenvalue.
        See e.g. https://doi.org/10.48550/arXiv.quant-ph/0504163

        Note that the decomposition is *not unique*! Therefore, this measure
        presents the entanglement for *some* decomposition into pure states,
        not necessarily the one that is anticipated when applying the Kraus
        channels.
        If a pure state is provided, this results in the same value as the
        Entanglement.meyer_wallach function if `always_decompose` flag is not set.

        Args:
            model (Model): The quantum circuit model.
            n_samples (int): Number of samples per qubit.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples.
            always_decompose (bool): Whether to explicitly compute the
                entantlement of formation for the eigendecomposition of a pure
                state.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: Entangling capacity of the given circuit, guaranteed
                to be between 0.0 and 1.0.
        """

        if scale:
            n_samples = jnp.power(2, model.n_qubits) * n_samples

        if n_samples is not None and n_samples > 0:
            model.initialize_params(random_key, repeat=n_samples)
        else:
            if len(model.params.shape) <= 2:
                model.params = model.params.reshape(1, *model.params.shape)
            else:
                log.info(f"Using sample size of model params: {model.params.shape[0]}")

        # implicitly set input to none in case it's not needed
        kwargs.setdefault("inputs", None)
        rhos = model(execution_type="density", **kwargs)
        rhos = rhos.reshape(-1, 2**model.n_qubits, 2**model.n_qubits)
        ent = cls._compute_entanglement_of_formation(
            rhos, model.n_qubits, always_decompose
        )
        return ent.mean()

    @classmethod
    def _compute_entanglement_of_formation(
        cls,
        rhos: jnp.ndarray,
        n_qubits: int,
        always_decompose: bool,
    ) -> jnp.ndarray:
        """
        Computes the entanglement of formation for a given batch of density
        matrices.

        Args:
            rho (jnp.ndarray): The density matrices, has shape (B_s, 2^n, 2^n),
                where B_s is the batch size and n the number of qubits.
            n_qubits (int): Number of qubits
            always_decompose (bool): Whether to explicitly compute the
                entantlement of formation for the eigendecomposition of a pure
                state.

        Returns:
            jnp.ndarray: Entanglement for the provided density matrices.
        """
        eigenvalues, eigenvectors = jnp.linalg.eigh(rhos)
        if not always_decompose and jnp.isclose(eigenvalues, 1.0).any(axis=-1).all():
            return cls._compute_meyer_wallach_meas(rhos, n_qubits)

        rhos = np.einsum("sij,sik->sijk", eigenvectors, eigenvectors.conjugate())
        measures = cls._compute_meyer_wallach_meas(
            rhos.reshape(-1, 2**n_qubits, 2**n_qubits), n_qubits
        )
        ent = np.einsum("si,si->s", measures.reshape(-1, 2**n_qubits), eigenvalues)
        return ent

    @classmethod
    def concentratable_entanglement(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Computes the concentratable entanglement of a given model.

        This method utilizes the Concentratable Entanglement measure from
        https://arxiv.org/abs/2104.06923.  The swap test is implemented
        directly in jaqsi using a ``3 * n_qubits`` circuit.

        Args:
            model (Model): The quantum circuit model.
            n_samples (int): The number of samples to compute the measure for.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples according to
                the number of qubits.
            **kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: Entangling capability of the given circuit, guaranteed
                to be between 0.0 and 1.0.
        """
        n = model.n_qubits
        N = 2**n

        if scale:
            n_samples = N * n_samples

        def _swap_test_circuit(
            params, inputs, pulse_params=None, random_key=None, **kw
        ):
            """Swap-test circuit on 3*n qubits."""
            from qml_essentials.tape import copy_to_tape

            def vari():
                model._variational(
                    params,
                    inputs,
                    pulse_params=pulse_params,
                    random_key=random_key,
                    **kw,
                )

            # First copy on wires n..2n-1
            copy_to_tape(vari, offset=n)
            # Second copy on wires 2n..3n-1
            copy_to_tape(vari, offset=2 * n)

            # Swap test: H on ancilla register (wires 0..n-1)
            for i in range(n):
                op.H(wires=i)

            for i in range(n):
                op.CSWAP(wires=[i, i + n, i + 2 * n])

            for i in range(n):
                op.H(wires=i)

        swap_script = js.Script(f=_swap_test_circuit, n_qubits=3 * n)

        if n_samples is not None and n_samples > 0:
            random_key = model.initialize_params(random_key, repeat=n_samples)
        else:
            if len(model.params.shape) <= 2:
                model.params = model.params.reshape(1, *model.params.shape)
            else:
                log.info(f"Using sample size of model params: {model.params.shape[0]}")

        params = model.params
        inputs = model._inputs_validation(kwargs.get("inputs", None))
        n_batch = params.shape[0]

        marg_probs = jax.jit(js.marginalize_probs, static_argnums=(1, 2))

        if n_batch > 1:
            from qml_essentials.utils import safe_random_split

            random_keys = safe_random_split(random_key, num=n_batch)
            probs = swap_script.execute(
                type="probs",
                args=(params, inputs, model.pulse_params, random_keys),
                in_axes=(0, None, None, 0),
                kwargs=kwargs,
            )
        else:
            probs = swap_script.execute(
                type="probs",
                args=(params, inputs, model.pulse_params, random_key),
                kwargs=kwargs,
            )

        # Marginalize to the ancilla register (wires 0..n-1)
        probs = marg_probs(probs, 3 * n, tuple(range(n)))

        ent = 1 - probs[..., 0]

        log.debug(f"Variance of measure: {ent.var()}")

        return float(ent.mean())

    @classmethod
    def concentratable_entanglement_estimation(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Computes the concentratable entanglement of a given model.

        This method utilizes the Concentratable Entanglement measure from
        https://arxiv.org/abs/2104.06923.  The swap test is implemented
        directly in jaqsi using a ``3 * n_qubits`` circuit.

        Args:
            model (Model): The quantum circuit model.
            n_samples (int): The number of samples to compute the measure for.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples according to
                the number of qubits.
            **kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: Entangling capability of the given circuit, guaranteed
                to be between 0.0 and 1.0.
        """
        n = model.n_qubits
        N = 2**n

        if scale:
            n_samples = N * n_samples

        def _bell_basis_measurement(
            params, inputs, pulse_params=None, random_key=None, **kw
        ):
            """Bell-basis measurement circuit on 3*n qubits."""
            from qml_essentials.tape import copy_to_tape

            def vari():
                model._variational(
                    params,
                    inputs,
                    pulse_params=pulse_params,
                    random_key=random_key,
                    **kw,
                )

            # First copy on wires 0..n-1
            copy_to_tape(vari, offset=0)
            # Second copy on wires n..2n-1
            copy_to_tape(vari, offset=n)

            for i in range(n):
                op.CX(wires=[i, i + n])
                op.H(wires=i)

        bell_basis_script = js.Script(f=_bell_basis_measurement, n_qubits=2 * n)

        if n_samples is not None and n_samples > 0:
            random_key = model.initialize_params(random_key, repeat=n_samples)
        else:
            if len(model.params.shape) <= 2:
                model.params = model.params.reshape(1, *model.params.shape)
            else:
                log.info(f"Using sample size of model params: {model.params.shape[0]}")

        params = model.params
        inputs = model._inputs_validation(kwargs.get("inputs", None))
        n_batch = params.shape[0]

        # SWAP operator in Bell-basis
        SWAP = jnp.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, -1]])
        # Construct observable for measuring CE
        CE_observable = op.Id([0, n]) + op.Operation([0, n], SWAP)
        for i in range(1, n):
            CE_observable = CE_observable @ (
                op.Id([i, i + n]) + op.Operation([i, i + n], SWAP)
            )
        CE_observable = (1 / N) * CE_observable

        expvals = []
        if n_batch > 1:
            from qml_essentials.utils import safe_random_split

            random_keys = safe_random_split(random_key, num=n_batch)
            expvals = bell_basis_script.execute(
                type="expval",
                obs=[CE_observable],
                args=(params, inputs, model.pulse_params, random_keys),
                in_axes=(0, None, None, 0),
                kwargs=kwargs,
            )
        else:
            expvals = bell_basis_script.execute(
                type="expval",
                obs=[CE_observable],
                args=(params, inputs, model.pulse_params, random_key),
                kwargs=kwargs,
            )

        ent = 1 - expvals
        log.debug(f"Variance of measure: {ent.var()}")
        return float(ent.mean())

bell_measurements(model, n_samples, random_key=None, scale=False, **kwargs) classmethod #

Compute the Bell measurement for a given model.

Constructs a 2 * n_qubits circuit that prepares two copies of the model state (on disjoint qubit registers), applies CNOTs and Hadamards, and measures probabilities on the first register.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples int

The number of samples to compute the measure for.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples according to the number of qubits.

False
**kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

The Bell measurement value.

Source code in qml_essentials/entanglement.py
@classmethod
def bell_measurements(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Compute the Bell measurement for a given model.

    Constructs a ``2 * n_qubits`` circuit that prepares two copies of
    the model state (on disjoint qubit registers), applies CNOTs and
    Hadamards, and measures probabilities on the first register.

    Args:
        model (Model): The quantum circuit model.
        n_samples (int): The number of samples to compute the measure for.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples
            according to the number of qubits.
        **kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: The Bell measurement value.
    """
    if "noise_params" in kwargs:
        log.warning(
            "Bell Measurements not suitable for noisy circuits. "
            "Consider 'concentratable entanglement' instead."
        )

    if scale:
        n_samples = jnp.power(2, model.n_qubits) * n_samples

    n = model.n_qubits

    def _bell_circuit(params, inputs, pulse_params=None, random_key=None, **kw):
        """Bell measurement circuit on 2*n qubits."""
        from qml_essentials.tape import copy_to_tape

        def vari():
            model._variational(
                params,
                inputs,
                pulse_params=pulse_params,
                random_key=random_key,
                **kw,
            )

        # First copy on wires 0..n-1
        vari()
        # Second copy on wires n..2n-1
        copy_to_tape(vari, offset=n)

        # Bell measurement: CNOT + H
        for q in range(n):
            op.CX(wires=[q, q + n])
            op.H(wires=q)

    bell_script = js.Script(f=_bell_circuit, n_qubits=2 * n)

    if n_samples is not None and n_samples > 0:
        random_key = model.initialize_params(random_key, repeat=n_samples)
        params = model.params
    else:
        if len(model.params.shape) <= 2:
            params = model.params.reshape(1, *model.params.shape)
        else:
            log.info(f"Using sample size of model params: {model.params.shape[0]}")
            params = model.params

    n_samples = params.shape[0]
    inputs = model._inputs_validation(kwargs.get("inputs", None))

    # Execute: vmap over batch dimension of params (axis 0)
    if n_samples > 1:
        from qml_essentials.utils import safe_random_split

        random_keys = safe_random_split(random_key, num=n_samples)
        result = bell_script.execute(
            type="probs",
            args=(params, inputs, model.pulse_params, random_keys),
            kwargs=kwargs,
            in_axes=(0, None, None, 0),
        )
    else:
        result = bell_script.execute(
            type="probs",
            args=(params, inputs, model.pulse_params, random_key),
            kwargs=kwargs,
        )

    # Marginalize: for each qubit q, keep wires [q, q+n] from the 2n-qubit probs
    # The last probability in each pair gives P(|11⟩) for that qubit pair
    per_qubit = []
    for q in range(n):
        marg = js.marginalize_probs(result, 2 * n, [q, q + n])
        per_qubit.append(marg)
    # per_qubit[q] has shape (n_samples, 4) or (4,)
    exp = jnp.stack(per_qubit, axis=-2)  # (..., n, 4)
    exp = 1 - 2 * exp[..., -1]  # (..., n)

    if not jnp.isclose(jnp.sum(exp.imag), 0, atol=1e-6):
        log.warning("Imaginary part of probabilities detected")
        exp = jnp.abs(exp)

    measure = 2 * (1 - exp.mean(axis=0))
    entangling_capability = min(max(float(measure.mean()), 0.0), 1.0)
    log.debug(f"Variance of measure: {measure.var()}")

    return entangling_capability

concentratable_entanglement(model, n_samples, random_key=None, scale=False, **kwargs) classmethod #

Computes the concentratable entanglement of a given model.

This method utilizes the Concentratable Entanglement measure from https://arxiv.org/abs/2104.06923. The swap test is implemented directly in jaqsi using a 3 * n_qubits circuit.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples int

The number of samples to compute the measure for.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples according to the number of qubits.

False
**kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

Entangling capability of the given circuit, guaranteed to be between 0.0 and 1.0.

Source code in qml_essentials/entanglement.py
@classmethod
def concentratable_entanglement(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Computes the concentratable entanglement of a given model.

    This method utilizes the Concentratable Entanglement measure from
    https://arxiv.org/abs/2104.06923.  The swap test is implemented
    directly in jaqsi using a ``3 * n_qubits`` circuit.

    Args:
        model (Model): The quantum circuit model.
        n_samples (int): The number of samples to compute the measure for.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples according to
            the number of qubits.
        **kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: Entangling capability of the given circuit, guaranteed
            to be between 0.0 and 1.0.
    """
    n = model.n_qubits
    N = 2**n

    if scale:
        n_samples = N * n_samples

    def _swap_test_circuit(
        params, inputs, pulse_params=None, random_key=None, **kw
    ):
        """Swap-test circuit on 3*n qubits."""
        from qml_essentials.tape import copy_to_tape

        def vari():
            model._variational(
                params,
                inputs,
                pulse_params=pulse_params,
                random_key=random_key,
                **kw,
            )

        # First copy on wires n..2n-1
        copy_to_tape(vari, offset=n)
        # Second copy on wires 2n..3n-1
        copy_to_tape(vari, offset=2 * n)

        # Swap test: H on ancilla register (wires 0..n-1)
        for i in range(n):
            op.H(wires=i)

        for i in range(n):
            op.CSWAP(wires=[i, i + n, i + 2 * n])

        for i in range(n):
            op.H(wires=i)

    swap_script = js.Script(f=_swap_test_circuit, n_qubits=3 * n)

    if n_samples is not None and n_samples > 0:
        random_key = model.initialize_params(random_key, repeat=n_samples)
    else:
        if len(model.params.shape) <= 2:
            model.params = model.params.reshape(1, *model.params.shape)
        else:
            log.info(f"Using sample size of model params: {model.params.shape[0]}")

    params = model.params
    inputs = model._inputs_validation(kwargs.get("inputs", None))
    n_batch = params.shape[0]

    marg_probs = jax.jit(js.marginalize_probs, static_argnums=(1, 2))

    if n_batch > 1:
        from qml_essentials.utils import safe_random_split

        random_keys = safe_random_split(random_key, num=n_batch)
        probs = swap_script.execute(
            type="probs",
            args=(params, inputs, model.pulse_params, random_keys),
            in_axes=(0, None, None, 0),
            kwargs=kwargs,
        )
    else:
        probs = swap_script.execute(
            type="probs",
            args=(params, inputs, model.pulse_params, random_key),
            kwargs=kwargs,
        )

    # Marginalize to the ancilla register (wires 0..n-1)
    probs = marg_probs(probs, 3 * n, tuple(range(n)))

    ent = 1 - probs[..., 0]

    log.debug(f"Variance of measure: {ent.var()}")

    return float(ent.mean())

concentratable_entanglement_estimation(model, n_samples, random_key=None, scale=False, **kwargs) classmethod #

Computes the concentratable entanglement of a given model.

This method utilizes the Concentratable Entanglement measure from https://arxiv.org/abs/2104.06923. The swap test is implemented directly in jaqsi using a 3 * n_qubits circuit.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples int

The number of samples to compute the measure for.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples according to the number of qubits.

False
**kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

Entangling capability of the given circuit, guaranteed to be between 0.0 and 1.0.

Source code in qml_essentials/entanglement.py
@classmethod
def concentratable_entanglement_estimation(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Computes the concentratable entanglement of a given model.

    This method utilizes the Concentratable Entanglement measure from
    https://arxiv.org/abs/2104.06923.  The swap test is implemented
    directly in jaqsi using a ``3 * n_qubits`` circuit.

    Args:
        model (Model): The quantum circuit model.
        n_samples (int): The number of samples to compute the measure for.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples according to
            the number of qubits.
        **kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: Entangling capability of the given circuit, guaranteed
            to be between 0.0 and 1.0.
    """
    n = model.n_qubits
    N = 2**n

    if scale:
        n_samples = N * n_samples

    def _bell_basis_measurement(
        params, inputs, pulse_params=None, random_key=None, **kw
    ):
        """Bell-basis measurement circuit on 3*n qubits."""
        from qml_essentials.tape import copy_to_tape

        def vari():
            model._variational(
                params,
                inputs,
                pulse_params=pulse_params,
                random_key=random_key,
                **kw,
            )

        # First copy on wires 0..n-1
        copy_to_tape(vari, offset=0)
        # Second copy on wires n..2n-1
        copy_to_tape(vari, offset=n)

        for i in range(n):
            op.CX(wires=[i, i + n])
            op.H(wires=i)

    bell_basis_script = js.Script(f=_bell_basis_measurement, n_qubits=2 * n)

    if n_samples is not None and n_samples > 0:
        random_key = model.initialize_params(random_key, repeat=n_samples)
    else:
        if len(model.params.shape) <= 2:
            model.params = model.params.reshape(1, *model.params.shape)
        else:
            log.info(f"Using sample size of model params: {model.params.shape[0]}")

    params = model.params
    inputs = model._inputs_validation(kwargs.get("inputs", None))
    n_batch = params.shape[0]

    # SWAP operator in Bell-basis
    SWAP = jnp.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, -1]])
    # Construct observable for measuring CE
    CE_observable = op.Id([0, n]) + op.Operation([0, n], SWAP)
    for i in range(1, n):
        CE_observable = CE_observable @ (
            op.Id([i, i + n]) + op.Operation([i, i + n], SWAP)
        )
    CE_observable = (1 / N) * CE_observable

    expvals = []
    if n_batch > 1:
        from qml_essentials.utils import safe_random_split

        random_keys = safe_random_split(random_key, num=n_batch)
        expvals = bell_basis_script.execute(
            type="expval",
            obs=[CE_observable],
            args=(params, inputs, model.pulse_params, random_keys),
            in_axes=(0, None, None, 0),
            kwargs=kwargs,
        )
    else:
        expvals = bell_basis_script.execute(
            type="expval",
            obs=[CE_observable],
            args=(params, inputs, model.pulse_params, random_key),
            kwargs=kwargs,
        )

    ent = 1 - expvals
    log.debug(f"Variance of measure: {ent.var()}")
    return float(ent.mean())

entanglement_of_formation(model, n_samples, random_key=None, scale=False, always_decompose=False, **kwargs) classmethod #

This function implements the entanglement of formation for mixed quantum systems. In that a mixed state gets decomposed into pure states with respective probabilities using the eigendecomposition of the density matrix. Then, the Meyer-Wallach measure is computed for each pure state, weighted by the eigenvalue. See e.g. https://doi.org/10.48550/arXiv.quant-ph/0504163

Note that the decomposition is not unique! Therefore, this measure presents the entanglement for some decomposition into pure states, not necessarily the one that is anticipated when applying the Kraus channels. If a pure state is provided, this results in the same value as the Entanglement.meyer_wallach function if always_decompose flag is not set.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples int

Number of samples per qubit.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples.

False
always_decompose bool

Whether to explicitly compute the entantlement of formation for the eigendecomposition of a pure state.

False
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

Entangling capacity of the given circuit, guaranteed to be between 0.0 and 1.0.

Source code in qml_essentials/entanglement.py
@classmethod
def entanglement_of_formation(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    always_decompose: bool = False,
    **kwargs: Any,
) -> float:
    """
    This function implements the entanglement of formation for mixed
    quantum systems.
    In that a mixed state gets decomposed into pure states with respective
    probabilities using the eigendecomposition of the density matrix.
    Then, the Meyer-Wallach measure is computed for each pure state,
    weighted by the eigenvalue.
    See e.g. https://doi.org/10.48550/arXiv.quant-ph/0504163

    Note that the decomposition is *not unique*! Therefore, this measure
    presents the entanglement for *some* decomposition into pure states,
    not necessarily the one that is anticipated when applying the Kraus
    channels.
    If a pure state is provided, this results in the same value as the
    Entanglement.meyer_wallach function if `always_decompose` flag is not set.

    Args:
        model (Model): The quantum circuit model.
        n_samples (int): Number of samples per qubit.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples.
        always_decompose (bool): Whether to explicitly compute the
            entantlement of formation for the eigendecomposition of a pure
            state.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: Entangling capacity of the given circuit, guaranteed
            to be between 0.0 and 1.0.
    """

    if scale:
        n_samples = jnp.power(2, model.n_qubits) * n_samples

    if n_samples is not None and n_samples > 0:
        model.initialize_params(random_key, repeat=n_samples)
    else:
        if len(model.params.shape) <= 2:
            model.params = model.params.reshape(1, *model.params.shape)
        else:
            log.info(f"Using sample size of model params: {model.params.shape[0]}")

    # implicitly set input to none in case it's not needed
    kwargs.setdefault("inputs", None)
    rhos = model(execution_type="density", **kwargs)
    rhos = rhos.reshape(-1, 2**model.n_qubits, 2**model.n_qubits)
    ent = cls._compute_entanglement_of_formation(
        rhos, model.n_qubits, always_decompose
    )
    return ent.mean()

meyer_wallach(model, n_samples, random_key=None, scale=False, **kwargs) classmethod #

Calculates the entangling capacity of a given quantum circuit using Meyer-Wallach measure.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples Optional[int]

Number of samples per qubit. If None or < 0, the current parameters of the model are used.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples.

False
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

Entangling capacity of the given circuit, guaranteed to be between 0.0 and 1.0.

Source code in qml_essentials/entanglement.py
@classmethod
def meyer_wallach(
    cls,
    model: Model,
    n_samples: Optional[int | None],
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Calculates the entangling capacity of a given quantum circuit
    using Meyer-Wallach measure.

    Args:
        model (Model): The quantum circuit model.
        n_samples (Optional[int]): Number of samples per qubit.
            If None or < 0, the current parameters of the model are used.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: Entangling capacity of the given circuit, guaranteed
            to be between 0.0 and 1.0.
    """
    if "noise_params" in kwargs:
        log.warning(
            "Meyer-Wallach measure not suitable for noisy circuits. "
            "Consider 'concentratable entanglement' instead."
        )

    if scale:
        n_samples = jnp.power(2, model.n_qubits) * n_samples

    if n_samples is not None and n_samples > 0:
        random_key = model.initialize_params(random_key, repeat=n_samples)

    # implicitly set input to none in case it's not needed
    kwargs.setdefault("inputs", None)
    # explicitly set execution type because everything else won't work
    rhos = model(execution_type="density", **kwargs).reshape(
        -1, 2**model.n_qubits, 2**model.n_qubits
    )

    ent = cls._compute_meyer_wallach_meas(rhos, model.n_qubits)

    log.debug(f"Variance of measure: {ent.var()}")

    return ent.mean()

relative_entropy(model, n_samples, n_sigmas, random_key=None, scale=False, **kwargs) classmethod #

Calculates the relative entropy of entanglement of a given quantum circuit. This measure is also applicable to mixed state, albeit it might me not fully accurate in this simplified case.

As the relative entropy is generally defined as the smallest relative entropy from the state in question to the set of separable states. However, as computing the nearest separable state is NP-hard, we select n_sigmas of random separable states to compute the distance to, which is not necessarily the nearest. Thus, this measure of entanglement presents an upper limit of entanglement.

As the relative entropy is not necessarily between zero and one, this function also normalises by the relative entroy to the GHZ state.

Parameters:

Name Type Description Default
model Model

The quantum circuit model.

required
n_samples int

Number of samples per qubit. If <= 0, the current parameters of the model are used.

required
n_sigmas int

Number of random separable pure states to compare against.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples.

False
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

Entangling capacity of the given circuit, guaranteed to be between 0.0 and 1.0.

Source code in qml_essentials/entanglement.py
@classmethod
def relative_entropy(
    cls,
    model: Model,
    n_samples: int,
    n_sigmas: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Calculates the relative entropy of entanglement of a given quantum
    circuit. This measure is also applicable to mixed state, albeit it
    might me not fully accurate in this simplified case.

    As the relative entropy is generally defined as the smallest relative
    entropy from the state in question to the set of separable states.
    However, as computing the nearest separable state is NP-hard, we select
    n_sigmas of random separable states to compute the distance to, which
    is not necessarily the nearest. Thus, this measure of entanglement
    presents an upper limit of entanglement.

    As the relative entropy is not necessarily between zero and one, this
    function also normalises by the relative entroy to the GHZ state.

    Args:
        model (Model): The quantum circuit model.
        n_samples (int): Number of samples per qubit.
            If <= 0, the current parameters of the model are used.
        n_sigmas (int): Number of random separable pure states to compare against.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: Entangling capacity of the given circuit, guaranteed
            to be between 0.0 and 1.0.
    """
    dim = jnp.power(2, model.n_qubits)
    if scale:
        n_samples = dim * n_samples
        n_sigmas = dim * n_sigmas

    if random_key is None:
        random_key = model.random_key

    # Random separable states
    log_sigmas = sample_random_separable_states(
        model.n_qubits, n_samples=n_sigmas, random_key=random_key, take_log=True
    )

    random_key, _ = jax.random.split(random_key)

    if n_samples is not None and n_samples > 0:
        model.initialize_params(random_key, repeat=n_samples)
    else:
        if len(model.params.shape) <= 2:
            model.params = model.params.reshape(1, *model.params.shape)
        else:
            log.info(f"Using sample size of model params: {model.params.shape[0]}")

    rhos, log_rhos = cls._compute_log_density(model, **kwargs)

    rel_entropies = jnp.zeros((n_sigmas, model.params.shape[0]))

    for i, log_sigma in enumerate(log_sigmas):
        rel_entropies = rel_entropies.at[i].set(
            cls._compute_rel_entropies(rhos, log_rhos, log_sigma)
        )

    # Entropy of GHZ states should be maximal
    ghz_model = Model(model.n_qubits, 1, "GHZ", data_reupload=False)
    rho_ghz, log_rho_ghz = cls._compute_log_density(ghz_model, **kwargs)
    ghz_entropies = cls._compute_rel_entropies(rho_ghz, log_rho_ghz, log_sigmas)

    normalised_entropies = rel_entropies / ghz_entropies

    # Average all iterated states
    entangling_capability = normalised_entropies.T.min(axis=1)
    log.debug(f"Variance of measure: {entangling_capability.var()}")

    return entangling_capability.mean()

Expressibility#

from qml_essentials.expressibility import Expressibility
Source code in qml_essentials/expressibility.py
class Expressibility:
    @classmethod
    def _sample_state_fidelities(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        kwargs: Any = None,
    ) -> jnp.ndarray:
        """
        Compute the fidelities for each parameter set.

        Args:
            model (Callable): Function that models the quantum circuit.
            n_samples (int): Number of parameter sets to generate.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            jnp.ndarray: Array of shape (n_samples,) containing the fidelities.
        """
        # Generate random parameter sets
        # We need two sets of parameters, as we are computing fidelities for a
        # pair of random state vectors
        model.initialize_params(random_key, repeat=n_samples * 2)

        # Evaluate the model for all parameters
        # Execution type is explicitly set to density
        sv: jnp.ndarray = model(
            params=model.params,
            execution_type="density",
            **kwargs,
        )

        # $\sqrt{\rho}$
        sqrt_sv1: jnp.ndarray = jnp.array([sqrtm(m) for m in sv[:n_samples]])

        # $\sqrt{\rho} \sigma \sqrt{\rho}$
        inner_fidelity = sqrt_sv1 @ sv[n_samples:] @ sqrt_sv1

        # Compute the fidelity using the partial trace of the statevector
        fidelity: jnp.ndarray = (
            jnp.trace(
                jnp.array([sqrtm(m) for m in inner_fidelity]),
                axis1=1,
                axis2=2,
            )
            ** 2
        )

        fidelity = jnp.abs(fidelity)

        return fidelity

    @classmethod
    def state_fidelities(
        cls,
        n_samples: int,
        n_bins: int,
        model: Model,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Sample the state fidelities and histogram them into a 2D array.

        Args:
            n_samples (int): Number of parameter sets to generate.
            n_bins (int): Number of histogram bins.
            model (Callable): Function that models the quantum circuit.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples and bins.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the bin edges,
            and histogram values.
        """
        if scale:
            n_samples = jnp.power(2, model.n_qubits) * n_samples
            n_bins = model.n_qubits * n_bins

        fidelities = cls._sample_state_fidelities(
            n_samples=n_samples,
            random_key=random_key,
            model=model,
            kwargs=kwargs,
        )

        y: jnp.ndarray = jnp.linspace(0, 1, n_bins + 1)

        z, _ = jnp.histogram(fidelities, bins=y)

        z = z / n_samples

        return y, z

    @classmethod
    def _haar_probability(cls, fidelity: float, n_qubits: int) -> float:
        """
        Calculates theoretical probability density function for random Haar states
        as proposed by Sim et al. (https://arxiv.org/abs/1905.10876).

        Args:
            fidelity (float): fidelity of two parameter assignments in [0, 1]
            n_qubits (int): number of qubits in the quantum system

        Returns:
            float: probability for a given fidelity
        """
        N = 2**n_qubits

        prob = (N - 1) * (1 - fidelity) ** (N - 2)
        return prob

    @classmethod
    def _sample_haar_integral(cls, n_qubits: int, n_bins: int) -> jnp.ndarray:
        """
        Calculates theoretical probability density function for random Haar states
        as proposed by Sim et al. (https://arxiv.org/abs/1905.10876) and bins it
        into a 2D-histogram.

        Args:
            n_qubits (int): number of qubits in the quantum system
            n_bins (int): number of histogram bins

        Returns:
            jnp.ndarray: probability distribution for all fidelities
        """
        dist = np.zeros(n_bins)
        for idx in range(n_bins):
            v = idx / n_bins
            u = (idx + 1) / n_bins
            dist[idx], _ = integrate.quad(cls._haar_probability, v, u, args=(n_qubits,))

        return dist

    @classmethod
    def haar_integral(
        cls,
        n_qubits: int,
        n_bins: int,
        cache: bool = True,
        scale: bool = False,
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Calculates theoretical probability density function for random Haar states
        as proposed by Sim et al. (https://arxiv.org/abs/1905.10876) and bins it
        into a 3D-histogram.

        Args:
            n_qubits (int): number of qubits in the quantum system
            n_bins (int): number of histogram bins
            cache (bool): whether to cache the haar integral
            scale (bool): whether to scale the number of bins

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]:
                - x component (bins): the input domain
                - y component (probabilities): the haar probability density
                  funtion for random Haar states
        """
        if scale:
            n_bins = n_qubits * n_bins

        x = jnp.linspace(0, 1, n_bins)

        if cache:
            name = f"haar_{n_qubits}q_{n_bins}s_{'scaled' if scale else ''}.npy"

            cache_folder = ".cache"
            if not os.path.exists(cache_folder):
                os.mkdir(cache_folder)

            file_path = os.path.join(cache_folder, name)

            if os.path.isfile(file_path):
                y = jnp.load(file_path)
                return x, y

        y = cls._sample_haar_integral(n_qubits, n_bins)

        if cache:
            jnp.save(file_path, y)

        return x, y

    @classmethod
    def kullback_leibler_divergence(
        cls,
        vqc_prob_dist: jnp.ndarray,
        haar_dist: jnp.ndarray,
    ) -> jnp.ndarray:
        """
        Calculates the KL divergence between two probability distributions (Haar
        probability distribution and the fidelity distribution sampled from a VQC).

        Args:
            vqc_prob_dist (jnp.ndarray): VQC fidelity probability distribution.
                Should have shape (n_inputs_samples, n_bins)
            haar_dist (jnp.ndarray): Haar probability distribution with shape.
                Should have shape (n_bins, )

        Returns:
            jnp.ndarray: Array of KL-Divergence values for all values in axis 1
        """
        if len(vqc_prob_dist.shape) > 1:
            assert all([haar_dist.shape == p.shape for p in vqc_prob_dist]), (
                "All probabilities for inputs should have the same shape as Haar. "
                f"Got {haar_dist.shape} for Haar and {vqc_prob_dist.shape} for VQC"
            )
        else:
            vqc_prob_dist = vqc_prob_dist.reshape((1, -1))

        kl_divergence = np.zeros(vqc_prob_dist.shape[0])
        for idx, p in enumerate(vqc_prob_dist):
            kl_divergence[idx] = jnp.sum(rel_entr(p, haar_dist))

        return kl_divergence

    @classmethod
    def kl_divergence_to_haar(
        cls,
        model: Model,
        n_samples: int,
        n_bins: int,
        random_key: Optional[jax.random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> float:
        """
        Shortcut method to compute the KL-Divergence bewteen a model and the
        Haar distribution. The basic steps are:
            - Sample the state fidelities for randomly initialised parameters.
            - Calculates the KL divergence between the sampled probability and
              the Haar probability distribution.

        Args:
            model (Model): Function that models the quantum circuit.
            n_samples (int): Number of parameter sets to generate.
            n_bins (int): Number of histogram bins.
            random_key (Optional[jax.random.PRNGKey]): JAX random key for
                parameter initialization. If None, uses the model's internal
                random key.
            scale (bool): Whether to scale the number of samples and bins.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: Tuple containing the
                input samples, bin edges, and histogram values.
        """
        _, fidelities = Expressibility.state_fidelities(
            model=model,
            random_key=random_key,
            n_samples=n_samples,
            n_bins=n_bins,
            scale=scale,
            **kwargs,
        )
        _, haar_probs = Expressibility.haar_integral(
            model.n_qubits, n_bins=n_bins, scale=scale
        )
        return Expressibility.kullback_leibler_divergence(fidelities, haar_probs)

haar_integral(n_qubits, n_bins, cache=True, scale=False) classmethod #

Calculates theoretical probability density function for random Haar states as proposed by Sim et al. (https://arxiv.org/abs/1905.10876) and bins it into a 3D-histogram.

Parameters:

Name Type Description Default
n_qubits int

number of qubits in the quantum system

required
n_bins int

number of histogram bins

required
cache bool

whether to cache the haar integral

True
scale bool

whether to scale the number of bins

False

Returns:

Type Description
Tuple[ndarray, ndarray]

Tuple[jnp.ndarray, jnp.ndarray]: - x component (bins): the input domain - y component (probabilities): the haar probability density funtion for random Haar states

Source code in qml_essentials/expressibility.py
@classmethod
def haar_integral(
    cls,
    n_qubits: int,
    n_bins: int,
    cache: bool = True,
    scale: bool = False,
) -> Tuple[jnp.ndarray, jnp.ndarray]:
    """
    Calculates theoretical probability density function for random Haar states
    as proposed by Sim et al. (https://arxiv.org/abs/1905.10876) and bins it
    into a 3D-histogram.

    Args:
        n_qubits (int): number of qubits in the quantum system
        n_bins (int): number of histogram bins
        cache (bool): whether to cache the haar integral
        scale (bool): whether to scale the number of bins

    Returns:
        Tuple[jnp.ndarray, jnp.ndarray]:
            - x component (bins): the input domain
            - y component (probabilities): the haar probability density
              funtion for random Haar states
    """
    if scale:
        n_bins = n_qubits * n_bins

    x = jnp.linspace(0, 1, n_bins)

    if cache:
        name = f"haar_{n_qubits}q_{n_bins}s_{'scaled' if scale else ''}.npy"

        cache_folder = ".cache"
        if not os.path.exists(cache_folder):
            os.mkdir(cache_folder)

        file_path = os.path.join(cache_folder, name)

        if os.path.isfile(file_path):
            y = jnp.load(file_path)
            return x, y

    y = cls._sample_haar_integral(n_qubits, n_bins)

    if cache:
        jnp.save(file_path, y)

    return x, y

kl_divergence_to_haar(model, n_samples, n_bins, random_key=None, scale=False, **kwargs) classmethod #

Shortcut method to compute the KL-Divergence bewteen a model and the Haar distribution. The basic steps are: - Sample the state fidelities for randomly initialised parameters. - Calculates the KL divergence between the sampled probability and the Haar probability distribution.

Parameters:

Name Type Description Default
model Model

Function that models the quantum circuit.

required
n_samples int

Number of parameter sets to generate.

required
n_bins int

Number of histogram bins.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples and bins.

False
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Type Description
float

Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: Tuple containing the input samples, bin edges, and histogram values.

Source code in qml_essentials/expressibility.py
@classmethod
def kl_divergence_to_haar(
    cls,
    model: Model,
    n_samples: int,
    n_bins: int,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> float:
    """
    Shortcut method to compute the KL-Divergence bewteen a model and the
    Haar distribution. The basic steps are:
        - Sample the state fidelities for randomly initialised parameters.
        - Calculates the KL divergence between the sampled probability and
          the Haar probability distribution.

    Args:
        model (Model): Function that models the quantum circuit.
        n_samples (int): Number of parameter sets to generate.
        n_bins (int): Number of histogram bins.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples and bins.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: Tuple containing the
            input samples, bin edges, and histogram values.
    """
    _, fidelities = Expressibility.state_fidelities(
        model=model,
        random_key=random_key,
        n_samples=n_samples,
        n_bins=n_bins,
        scale=scale,
        **kwargs,
    )
    _, haar_probs = Expressibility.haar_integral(
        model.n_qubits, n_bins=n_bins, scale=scale
    )
    return Expressibility.kullback_leibler_divergence(fidelities, haar_probs)

kullback_leibler_divergence(vqc_prob_dist, haar_dist) classmethod #

Calculates the KL divergence between two probability distributions (Haar probability distribution and the fidelity distribution sampled from a VQC).

Parameters:

Name Type Description Default
vqc_prob_dist ndarray

VQC fidelity probability distribution. Should have shape (n_inputs_samples, n_bins)

required
haar_dist ndarray

Haar probability distribution with shape. Should have shape (n_bins, )

required

Returns:

Type Description
ndarray

jnp.ndarray: Array of KL-Divergence values for all values in axis 1

Source code in qml_essentials/expressibility.py
@classmethod
def kullback_leibler_divergence(
    cls,
    vqc_prob_dist: jnp.ndarray,
    haar_dist: jnp.ndarray,
) -> jnp.ndarray:
    """
    Calculates the KL divergence between two probability distributions (Haar
    probability distribution and the fidelity distribution sampled from a VQC).

    Args:
        vqc_prob_dist (jnp.ndarray): VQC fidelity probability distribution.
            Should have shape (n_inputs_samples, n_bins)
        haar_dist (jnp.ndarray): Haar probability distribution with shape.
            Should have shape (n_bins, )

    Returns:
        jnp.ndarray: Array of KL-Divergence values for all values in axis 1
    """
    if len(vqc_prob_dist.shape) > 1:
        assert all([haar_dist.shape == p.shape for p in vqc_prob_dist]), (
            "All probabilities for inputs should have the same shape as Haar. "
            f"Got {haar_dist.shape} for Haar and {vqc_prob_dist.shape} for VQC"
        )
    else:
        vqc_prob_dist = vqc_prob_dist.reshape((1, -1))

    kl_divergence = np.zeros(vqc_prob_dist.shape[0])
    for idx, p in enumerate(vqc_prob_dist):
        kl_divergence[idx] = jnp.sum(rel_entr(p, haar_dist))

    return kl_divergence

state_fidelities(n_samples, n_bins, model, random_key=None, scale=False, **kwargs) classmethod #

Sample the state fidelities and histogram them into a 2D array.

Parameters:

Name Type Description Default
n_samples int

Number of parameter sets to generate.

required
n_bins int

Number of histogram bins.

required
model Callable

Function that models the quantum circuit.

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
scale bool

Whether to scale the number of samples and bins.

False
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Type Description
ndarray

Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the bin edges,

ndarray

and histogram values.

Source code in qml_essentials/expressibility.py
@classmethod
def state_fidelities(
    cls,
    n_samples: int,
    n_bins: int,
    model: Model,
    random_key: Optional[jax.random.PRNGKey] = None,
    scale: bool = False,
    **kwargs: Any,
) -> Tuple[jnp.ndarray, jnp.ndarray]:
    """
    Sample the state fidelities and histogram them into a 2D array.

    Args:
        n_samples (int): Number of parameter sets to generate.
        n_bins (int): Number of histogram bins.
        model (Callable): Function that models the quantum circuit.
        random_key (Optional[jax.random.PRNGKey]): JAX random key for
            parameter initialization. If None, uses the model's internal
            random key.
        scale (bool): Whether to scale the number of samples and bins.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the bin edges,
        and histogram values.
    """
    if scale:
        n_samples = jnp.power(2, model.n_qubits) * n_samples
        n_bins = model.n_qubits * n_bins

    fidelities = cls._sample_state_fidelities(
        n_samples=n_samples,
        random_key=random_key,
        model=model,
        kwargs=kwargs,
    )

    y: jnp.ndarray = jnp.linspace(0, 1, n_bins + 1)

    z, _ = jnp.histogram(fidelities, bins=y)

    z = z / n_samples

    return y, z

Coefficients#

from qml_essentials.coefficients import Coefficients
Source code in qml_essentials/coefficients.py
class Coefficients:
    @classmethod
    def get_spectrum(
        cls,
        model: Model,
        mfs: int = 1,
        mts: int = 1,
        shift=False,
        trim=False,
        numerical_cap: Optional[float] = -1,
        **kwargs,
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Extracts the coefficients of a given model using a FFT (jnp-fft).

        Note that the coefficients are complex numbers, but the imaginary part
        of the coefficients should be very close to zero, since the expectation
        values of the Pauli operators are real numbers.

        It can perform oversampling in both the frequency and time domain
        using the `mfs` and `mts` arguments.

        Args:
            model (Model): The model to sample.
            mfs (int): Multiplicator for the highest frequency. Default is 2.
            mts (int): Multiplicator for the number of time samples. Default is 1.
            shift (bool): Whether to apply jnp-fftshift. Default is False.
            trim (bool): Whether to remove the Nyquist frequency if spectrum is even.
                Default is False.
            numerical_cap (Optional[float]): Numerical cap for the coefficients.
                If positive, coefficients with magnitude below the cap are
                zeroed and, for a single input feature, frequencies that
                vanish entirely are removed from both `coeffs` and `freqs`.
            kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the coefficients
            and frequencies.
        """
        kwargs.setdefault("force_mean", True)
        kwargs.setdefault("execution_type", "expval")

        coeffs, freqs = cls._fourier_transform(model, mfs=mfs, mts=mts, **kwargs)

        if not jnp.isclose(jnp.sum(coeffs).imag, 0.0, atol=1.0e-6):
            raise ValueError(
                f"Spectrum is not real. Imaginary part of coefficients is:\
                {jnp.sum(coeffs).imag}"
            )

        if trim:
            for ax in range(model.n_input_feat):
                if coeffs.shape[ax] % 2 == 0:
                    coeffs = np.delete(coeffs, len(coeffs) // 2, axis=ax)
                    freqs = [np.delete(freq, len(freq) // 2, axis=ax) for freq in freqs]

        if shift:
            coeffs = jnp.fft.fftshift(coeffs, axes=list(range(model.n_input_feat)))
            freqs = np.fft.fftshift(freqs)

        if numerical_cap > 0:
            # set coeffs below threshold to zero
            coeffs = jnp.where(
                jnp.abs(coeffs) < numerical_cap,
                jnp.zeros_like(coeffs),
                coeffs,
            )

            # Drop frequencies whose coefficients vanish entirely after
            # capping, so the returned spectrum reflects only the surviving
            # frequencies. Well-defined only for a single (1-D) frequency
            # axis; for multi-dim input the rectangular grid is left intact.
            if model.n_input_feat == 1:
                if coeffs.ndim == 1:
                    surviving = coeffs != 0
                else:
                    surviving = jnp.any(coeffs != 0, axis=tuple(range(1, coeffs.ndim)))
                coeffs = coeffs[surviving]
                freqs = [freqs[0][surviving]]

        if len(freqs) == 1:
            freqs = freqs[0]

        return coeffs, freqs

    @classmethod
    def _fourier_transform(
        cls, model: Model, mfs: int, mts: int, **kwargs: Any
    ) -> jnp.ndarray:
        # Create a frequency vector with as many frequencies as model degrees,
        # oversampled by mfs
        n_freqs: jnp.ndarray = jnp.array(
            [mfs * model.degree[i] for i in range(model.n_input_feat)]
        )

        start, stop, step = 0, 2 * mts * jnp.pi, 2 * jnp.pi / n_freqs
        # Stretch according to the number of frequencies
        inputs: List = [
            jnp.arange(start, stop, step[i]) for i in range(model.n_input_feat)
        ]

        # permute with input dimensionality
        nd_inputs = jnp.array(
            jnp.meshgrid(*[inputs[i] for i in range(model.n_input_feat)])
        ).T.reshape(-1, model.n_input_feat)

        # Output vector is not necessarily the same length as input
        outputs = model(inputs=nd_inputs, **kwargs)
        outputs = outputs.reshape(
            *[inputs[i].shape[0] for i in range(model.n_input_feat)], -1
        ).squeeze()

        coeffs = jnp.fft.fftn(outputs, axes=list(range(model.n_input_feat)))

        freqs = [
            jnp.fft.fftfreq(int(mts * n_freqs[i]), 1 / n_freqs[i])
            for i in range(model.n_input_feat)
        ]
        # freqs = jnp.fft.fftfreq(mts * n_freqs, 1 / n_freqs)

        # TODO: this could cause issues with multidim input
        # FIXME: account for different frequencies in multidim input scenarios
        # Run the fft and rearrange +
        # normalize the output (using product if multidim)
        return (
            coeffs / math.prod(outputs.shape[0 : model.n_input_feat]),
            freqs,
        )

    @classmethod
    def get_psd(cls, coeffs: jnp.ndarray) -> jnp.ndarray:
        """
        Calculates the power spectral density (PSD) from given Fourier coefficients.

        Args:
            coeffs (jnp.ndarray): The Fourier coefficients.

        Returns:
            jnp.ndarray: The power spectral density.
        """
        # TODO: if we apply trim=True in advance, this will be slightly wrong..

        def abs2(x):
            return x.real**2 + x.imag**2

        scale = 2.0 / (len(coeffs) ** 2)
        return scale * abs2(coeffs)

    @classmethod
    def evaluate_Fourier_series(
        cls,
        coefficients: jnp.ndarray,
        frequencies: jnp.ndarray,
        inputs: Union[jnp.ndarray, list, float],
    ) -> float:
        """
        Evaluate the function value of a Fourier series at one point.

        Args:
            coefficients (jnp.ndarray): Coefficients of the Fourier series.
            frequencies (jnp.ndarray): Corresponding frequencies.
            inputs (jnp.ndarray): Point at which to evaluate the function.
        Returns:
            float: The function value at the input point.
        """
        coefficients = jnp.asarray(coefficients)

        def flatten_grid(freq_axes):
            freq_axes = [jnp.asarray(freq) for freq in freq_axes]
            freq_grid = jnp.stack(jnp.meshgrid(*freq_axes, indexing="ij"), axis=-1)
            flat_frequencies = freq_grid.reshape(-1, len(freq_axes))
            flat_coefficients = coefficients.reshape(
                flat_frequencies.shape[0], *coefficients.shape[len(freq_axes) :]
            )
            return flat_coefficients, flat_frequencies

        if isinstance(frequencies, list):
            flat_coefficients, flat_frequencies = flatten_grid(frequencies)
        else:
            frequencies = jnp.asarray(frequencies)
            if frequencies.ndim == 1:
                flat_frequencies = frequencies[:, jnp.newaxis]
                flat_coefficients = coefficients.reshape(
                    flat_frequencies.shape[0], *coefficients.shape[1:]
                )
            else:
                n_features, n_axis_freqs = frequencies.shape
                is_axis_frequencies = (
                    coefficients.shape[:n_features] == (n_axis_freqs,) * n_features
                )

                if is_axis_frequencies:
                    flat_coefficients, flat_frequencies = flatten_grid(frequencies)
                else:
                    flat_frequencies = frequencies
                    flat_coefficients = coefficients.reshape(
                        flat_frequencies.shape[0], *coefficients.shape[1:]
                    )

        inputs = jnp.asarray(inputs)
        if inputs.ndim == 0:
            inputs = inputs.reshape(1, 1)
        elif inputs.ndim == 1:
            if flat_frequencies.shape[1] == 1:
                inputs = inputs[:, jnp.newaxis]
            elif inputs.shape[0] == flat_frequencies.shape[1]:
                inputs = inputs[jnp.newaxis, :]
            else:
                inputs = jnp.repeat(
                    inputs[:, jnp.newaxis], flat_frequencies.shape[1], axis=1
                )
        exponents = jnp.exp(1j * (inputs @ flat_frequencies.T))
        exp = jnp.tensordot(exponents, flat_coefficients, axes=([1], [0]))

        return jnp.squeeze(jnp.real(exp))

evaluate_Fourier_series(coefficients, frequencies, inputs) classmethod #

Evaluate the function value of a Fourier series at one point.

Parameters:

Name Type Description Default
coefficients ndarray

Coefficients of the Fourier series.

required
frequencies ndarray

Corresponding frequencies.

required
inputs ndarray

Point at which to evaluate the function.

required

Returns: float: The function value at the input point.

Source code in qml_essentials/coefficients.py
@classmethod
def evaluate_Fourier_series(
    cls,
    coefficients: jnp.ndarray,
    frequencies: jnp.ndarray,
    inputs: Union[jnp.ndarray, list, float],
) -> float:
    """
    Evaluate the function value of a Fourier series at one point.

    Args:
        coefficients (jnp.ndarray): Coefficients of the Fourier series.
        frequencies (jnp.ndarray): Corresponding frequencies.
        inputs (jnp.ndarray): Point at which to evaluate the function.
    Returns:
        float: The function value at the input point.
    """
    coefficients = jnp.asarray(coefficients)

    def flatten_grid(freq_axes):
        freq_axes = [jnp.asarray(freq) for freq in freq_axes]
        freq_grid = jnp.stack(jnp.meshgrid(*freq_axes, indexing="ij"), axis=-1)
        flat_frequencies = freq_grid.reshape(-1, len(freq_axes))
        flat_coefficients = coefficients.reshape(
            flat_frequencies.shape[0], *coefficients.shape[len(freq_axes) :]
        )
        return flat_coefficients, flat_frequencies

    if isinstance(frequencies, list):
        flat_coefficients, flat_frequencies = flatten_grid(frequencies)
    else:
        frequencies = jnp.asarray(frequencies)
        if frequencies.ndim == 1:
            flat_frequencies = frequencies[:, jnp.newaxis]
            flat_coefficients = coefficients.reshape(
                flat_frequencies.shape[0], *coefficients.shape[1:]
            )
        else:
            n_features, n_axis_freqs = frequencies.shape
            is_axis_frequencies = (
                coefficients.shape[:n_features] == (n_axis_freqs,) * n_features
            )

            if is_axis_frequencies:
                flat_coefficients, flat_frequencies = flatten_grid(frequencies)
            else:
                flat_frequencies = frequencies
                flat_coefficients = coefficients.reshape(
                    flat_frequencies.shape[0], *coefficients.shape[1:]
                )

    inputs = jnp.asarray(inputs)
    if inputs.ndim == 0:
        inputs = inputs.reshape(1, 1)
    elif inputs.ndim == 1:
        if flat_frequencies.shape[1] == 1:
            inputs = inputs[:, jnp.newaxis]
        elif inputs.shape[0] == flat_frequencies.shape[1]:
            inputs = inputs[jnp.newaxis, :]
        else:
            inputs = jnp.repeat(
                inputs[:, jnp.newaxis], flat_frequencies.shape[1], axis=1
            )
    exponents = jnp.exp(1j * (inputs @ flat_frequencies.T))
    exp = jnp.tensordot(exponents, flat_coefficients, axes=([1], [0]))

    return jnp.squeeze(jnp.real(exp))

get_psd(coeffs) classmethod #

Calculates the power spectral density (PSD) from given Fourier coefficients.

Parameters:

Name Type Description Default
coeffs ndarray

The Fourier coefficients.

required

Returns:

Type Description
ndarray

jnp.ndarray: The power spectral density.

Source code in qml_essentials/coefficients.py
@classmethod
def get_psd(cls, coeffs: jnp.ndarray) -> jnp.ndarray:
    """
    Calculates the power spectral density (PSD) from given Fourier coefficients.

    Args:
        coeffs (jnp.ndarray): The Fourier coefficients.

    Returns:
        jnp.ndarray: The power spectral density.
    """
    # TODO: if we apply trim=True in advance, this will be slightly wrong..

    def abs2(x):
        return x.real**2 + x.imag**2

    scale = 2.0 / (len(coeffs) ** 2)
    return scale * abs2(coeffs)

get_spectrum(model, mfs=1, mts=1, shift=False, trim=False, numerical_cap=-1, **kwargs) classmethod #

Extracts the coefficients of a given model using a FFT (jnp-fft).

Note that the coefficients are complex numbers, but the imaginary part of the coefficients should be very close to zero, since the expectation values of the Pauli operators are real numbers.

It can perform oversampling in both the frequency and time domain using the mfs and mts arguments.

Parameters:

Name Type Description Default
model Model

The model to sample.

required
mfs int

Multiplicator for the highest frequency. Default is 2.

1
mts int

Multiplicator for the number of time samples. Default is 1.

1
shift bool

Whether to apply jnp-fftshift. Default is False.

False
trim bool

Whether to remove the Nyquist frequency if spectrum is even. Default is False.

False
numerical_cap Optional[float]

Numerical cap for the coefficients. If positive, coefficients with magnitude below the cap are zeroed and, for a single input feature, frequencies that vanish entirely are removed from both coeffs and freqs.

-1
kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Type Description
ndarray

Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the coefficients

ndarray

and frequencies.

Source code in qml_essentials/coefficients.py
@classmethod
def get_spectrum(
    cls,
    model: Model,
    mfs: int = 1,
    mts: int = 1,
    shift=False,
    trim=False,
    numerical_cap: Optional[float] = -1,
    **kwargs,
) -> Tuple[jnp.ndarray, jnp.ndarray]:
    """
    Extracts the coefficients of a given model using a FFT (jnp-fft).

    Note that the coefficients are complex numbers, but the imaginary part
    of the coefficients should be very close to zero, since the expectation
    values of the Pauli operators are real numbers.

    It can perform oversampling in both the frequency and time domain
    using the `mfs` and `mts` arguments.

    Args:
        model (Model): The model to sample.
        mfs (int): Multiplicator for the highest frequency. Default is 2.
        mts (int): Multiplicator for the number of time samples. Default is 1.
        shift (bool): Whether to apply jnp-fftshift. Default is False.
        trim (bool): Whether to remove the Nyquist frequency if spectrum is even.
            Default is False.
        numerical_cap (Optional[float]): Numerical cap for the coefficients.
            If positive, coefficients with magnitude below the cap are
            zeroed and, for a single input feature, frequencies that
            vanish entirely are removed from both `coeffs` and `freqs`.
        kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the coefficients
        and frequencies.
    """
    kwargs.setdefault("force_mean", True)
    kwargs.setdefault("execution_type", "expval")

    coeffs, freqs = cls._fourier_transform(model, mfs=mfs, mts=mts, **kwargs)

    if not jnp.isclose(jnp.sum(coeffs).imag, 0.0, atol=1.0e-6):
        raise ValueError(
            f"Spectrum is not real. Imaginary part of coefficients is:\
            {jnp.sum(coeffs).imag}"
        )

    if trim:
        for ax in range(model.n_input_feat):
            if coeffs.shape[ax] % 2 == 0:
                coeffs = np.delete(coeffs, len(coeffs) // 2, axis=ax)
                freqs = [np.delete(freq, len(freq) // 2, axis=ax) for freq in freqs]

    if shift:
        coeffs = jnp.fft.fftshift(coeffs, axes=list(range(model.n_input_feat)))
        freqs = np.fft.fftshift(freqs)

    if numerical_cap > 0:
        # set coeffs below threshold to zero
        coeffs = jnp.where(
            jnp.abs(coeffs) < numerical_cap,
            jnp.zeros_like(coeffs),
            coeffs,
        )

        # Drop frequencies whose coefficients vanish entirely after
        # capping, so the returned spectrum reflects only the surviving
        # frequencies. Well-defined only for a single (1-D) frequency
        # axis; for multi-dim input the rectangular grid is left intact.
        if model.n_input_feat == 1:
            if coeffs.ndim == 1:
                surviving = coeffs != 0
            else:
                surviving = jnp.any(coeffs != 0, axis=tuple(range(1, coeffs.ndim)))
            coeffs = coeffs[surviving]
            freqs = [freqs[0][surviving]]

    if len(freqs) == 1:
        freqs = freqs[0]

    return coeffs, freqs

Fourier Tree#

from qml_essentials.coefficients import FourierTree

Sine-cosine tree representation for the algorithm by Nemkov et al.

Computes the analytical Fourier coefficients/frequencies of a Pauli-Clifford circuit. The symbolic structure of the tree (which Pauli rotations contribute sine/cosine factors to which leaf, and the leaf observables) is built once in NumPy; the parameter-dependent coefficients are then obtained with a small number of vectorised JAX operations, so the result remains jittable / differentiable with respect to the model parameters.

The resulting spectrum is the d-dimensional set of frequency vectors, where \(d\) is the input dimensionality.

Usage:

model = Model(...)
tree = FourierTree(model)
exp = tree()                          # expectation value
coeff_list, freq_list = tree.get_spectrum()

Source code in qml_essentials/coefficients.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
class FourierTree:
    """
    Sine-cosine tree representation for the algorithm by Nemkov et al.

    Computes the analytical Fourier coefficients/frequencies of a Pauli-Clifford
    circuit.  The symbolic structure of the tree (which Pauli rotations
    contribute sine/cosine factors to which leaf, and the leaf observables) is
    built once in NumPy; the parameter-dependent coefficients are then obtained
    with a small number of vectorised JAX operations, so the result remains
    jittable / differentiable with respect to the model parameters.

    The resulting spectrum is the d-dimensional set of frequency vectors,
    where $d$ is the input dimensionality.

    **Usage**:
    ```
    model = Model(...)
    tree = FourierTree(model)
    exp = tree()                          # expectation value
    coeff_list, freq_list = tree.get_spectrum()
    ```
    """

    def __init__(self, model: Model):
        """
        Tree initialisation, based on the Pauli-Clifford representation of a
        model.

        Args:
            model (Model): The Model, for which to build the tree.
        """
        self.model = model
        self.n_qubits = model.n_qubits

        # A single (de-batched) parameter set drives the whole tree.
        self._params = self._single_param_set(model.params)

        # Canonical Pauli-Clifford structure, recorded once at a fixed base
        # input.  The base value is irrelevant to the structure (it only sets
        # the rotation angles, not which Pauli words appear).
        base_inputs = np.ones(model.n_input_feat)
        operations, observables = self._build_canonical_tape(self._params, base_inputs)

        self.parameters = [
            jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations)
        ]
        self.n_params = len(self.parameters)

        # Pauli generators of the (canonical) rotations, as symbolic words.
        self.pauli_words: List[PauliWord] = [
            PauliWord.from_operation(op, self.n_qubits) for op in operations
        ]

        # Cumulative X/Y support of the rotations[0..k] (for light-cone early
        # stopping).  cumulative_xy[k] is True on every qubit touched by an X/Y
        # generator in any rotation up to index k.
        self.cumulative_xy: List[np.ndarray] = []
        running = np.zeros(self.n_qubits, dtype=bool)
        for pw in self.pauli_words:
            running = np.logical_or(running, pw.xy_mask)
            self.cumulative_xy.append(running.copy())

        # Observable Pauli words (one tree root each).
        self.observable_words: List[PauliWord] = [
            PauliWord.from_operation(obs, self.n_qubits) for obs in observables
        ]

        # Identify the input-encoding columns, their feature, and integer
        # frequency scaling directly from the tape (no per-gate tagging).  Sets
        # ``input_indices``, ``all_input_indices``, ``input_scaling``,
        # ``var_positions`` and ``features``.
        self._detect_inputs(base_inputs)

        # The explicit leaf structure is built lazily: for deep circuits the
        # number of tree paths explodes combinatorially, while the canonical
        # form above (and the merged-state support DP) remain cheap.
        self._structure_built = False

    def _ensure_structure(self) -> None:
        """Build the explicit leaf/spectrum structure on first use."""
        if not self._structure_built:
            # Symbolic structure: per root (S, C, terms) leaf arrays ...
            self._build_leaf_arrays()
            # ... and the parameter-independent frequency/weight structure.
            self._build_spectrum_structure()
            self._structure_built = True

    def _single_param_set(self, params) -> jnp.ndarray:
        """De-batch the model parameters to the single set the tree describes.

        Models can carry batched parameters (e.g. after FCC sampling); the tree
        is defined for one set, so fall back to the first and warn.
        """
        params = jnp.asarray(params)
        if params.ndim > 2 and params.shape[0] > 1:
            warnings.warn(
                f"FourierTree supports a single parameter set; using the first "
                f"of {params.shape[0]} batched parameter sets.",
                UserWarning,
            )
            params = params[0]
        return params

    def _build_canonical_tape(self, params, inputs):
        """Record the circuit and transform it to Pauli-Clifford normal form.

        Returns the ``(operations, observables)`` of the canonical circuit
        (see :meth:`PauliCircuit.from_parameterised_circuit`).
        """
        params = self._single_param_set(params)
        inputs = self.model._inputs_validation(inputs)
        raw_tape = self.model.script._record(params=params, inputs=inputs)
        _, obs_list = self.model._build_obs()
        return PauliCircuit.from_parameterised_circuit(
            raw_tape, observables=obs_list, n_qubits=self.n_qubits
        )

    def _canonical_parameters(self, inputs) -> np.ndarray:
        """Recorded canonical rotation angles (1-D float array) for ``inputs``."""
        operations, _ = self._build_canonical_tape(self._params, inputs)
        return np.array(
            [float(jnp.squeeze(p)) for p in PauliCircuit.get_parameters(operations)]
        )

    def _detect_inputs(self, base_inputs: np.ndarray) -> None:
        r"""Infer the input-encoding columns directly from the tape (tag-free).

        Each encoding rotation applies an angle :math:`\omega_k\,x_f` that is
        linear in a single input feature :math:`x_f`, and Clifford commutation
        only multiplies a rotation generator by :math:`\pm 1`.  Every canonical
        rotation angle is therefore an affine function of the inputs, so
        perturbing one feature at a time and differencing the recorded angles
        isolates exactly the columns that depend on it, together with the
        signed integer scaling :math:`\omega_k`.

        Sets :attr:`input_indices` (``{feature: [columns]}``),
        :attr:`all_input_indices`, :attr:`input_scaling` (per column, ``1`` for
        variational columns), :attr:`var_positions`, and :attr:`features`.

        Raises:
            NotImplementedError: If a rotation depends on more than one feature
                (the tree requires single-feature encodings).
        """
        tol = 1e-6
        d = self.model.n_input_feat
        base = np.asarray(base_inputs, dtype=float)
        p_base = np.array([float(p) for p in self.parameters])

        # response[f, k] = d(angle_k) / d(x_f), the linear response of column k.
        response = np.zeros((d, self.n_params))
        for f in range(d):
            step = base.copy()
            step[f] += 1.0
            response[f] = self._canonical_parameters(step) - p_base

        input_indices: Dict[int, list] = defaultdict(list)
        all_input_indices: List[int] = []
        scaling = np.ones(self.n_params, dtype=np.int64)
        for k in range(self.n_params):
            feats = np.flatnonzero(np.abs(response[:, k]) > tol)
            if feats.size == 0:
                continue  # variational column
            if feats.size > 1:
                raise NotImplementedError(
                    f"Rotation {k} depends on multiple input features "
                    f"{feats.tolist()}; the Fourier tree requires each encoding "
                    "rotation to be linear in a single feature."
                )
            f = int(feats[0])
            omega = float(response[f, k])
            w = int(round(omega))
            if abs(omega - w) > tol:
                warnings.warn(
                    f"Non-integer input scaling {omega:.4f} on rotation {k} "
                    f"(feature {f}); rounding to {w}. The Fourier tree supports "
                    "integer frequency scalings only.",
                    UserWarning,
                )
            input_indices[f].append(k)
            all_input_indices.append(k)
            scaling[k] = w

        self.input_indices = input_indices
        self.all_input_indices = all_input_indices
        self.input_scaling = scaling
        input_set = set(all_input_indices)
        self.var_positions = np.array(
            [i for i in range(self.n_params) if i not in input_set], dtype=np.int64
        )
        # Ordered list of input feature keys (d-dimensional spectrum).
        self.features = sorted(input_indices.keys())

    # Symbolic tree construction (NumPy)
    def _build_leaf_arrays(self) -> None:
        """Collect the tree leaves for every root into integer count matrices.

        For each root (observable) this produces:
            - ``S``: (n_leaves, n_params) sine-factor counts per parameter,
            - ``C``: (n_leaves, n_params) cosine-factor counts per parameter,
            - ``terms``: (n_leaves,) complex leaf constants ``<0|O_leaf|0>``.
        """
        self.leaf_arrays: List[Tuple[np.ndarray, np.ndarray, np.ndarray]] = []
        for obs_word in self.observable_words:
            leaves: List[Tuple[np.ndarray, np.ndarray, complex]] = []
            zeros = np.zeros(self.n_params, dtype=np.int64)
            self._collect_leaves(
                obs_word, self.n_params - 1, zeros.copy(), zeros.copy(), leaves
            )
            if leaves:
                S = np.stack([leaf[0] for leaf in leaves])
                C = np.stack([leaf[1] for leaf in leaves])
                terms = np.array([leaf[2] for leaf in leaves], dtype=np.complex128)
            else:
                S = np.zeros((0, self.n_params), dtype=np.int64)
                C = np.zeros((0, self.n_params), dtype=np.int64)
                terms = np.zeros(0, dtype=np.complex128)
            self.leaf_arrays.append((S, C, terms))

    def _collect_leaves(
        self,
        observable: PauliWord,
        pauli_idx: int,
        sin_counts: np.ndarray,
        cos_counts: np.ndarray,
        leaves: List[Tuple[np.ndarray, np.ndarray, complex]],
    ) -> None:
        """Recursively enumerate the leaves of the coefficient tree.

        The incoming sine/cosine factor (from the parent edge) is already
        accumulated into ``sin_counts``/``cos_counts``.  This fuses the tree
        construction and leaf traversal of the original implementation into a
        single NumPy pass (no per-node JAX scatter updates).
        """
        if self._early_stopping_possible(pauli_idx, observable):
            return

        # Skip trailing Pauli rotations that commute with the observable.
        while pauli_idx >= 0:
            last = self.pauli_words[pauli_idx]
            if not observable.commutes_with(last):
                break
            pauli_idx -= 1
        else:  # leaf reached
            term = observable.zero_expectation()
            if term != 0:
                leaves.append((sin_counts, cos_counts, term))
            return

        last = self.pauli_words[pauli_idx]

        # Left child: cosine factor for this parameter, same observable.
        cos_left = cos_counts.copy()
        cos_left[pauli_idx] += 1
        self._collect_leaves(
            observable, pauli_idx - 1, sin_counts.copy(), cos_left, leaves
        )

        # Right child: sine factor, observable becomes  P . O.
        sin_right = sin_counts.copy()
        sin_right[pauli_idx] += 1
        self._collect_leaves(
            last.compose(observable),
            pauli_idx - 1,
            sin_right,
            cos_counts.copy(),
            leaves,
        )

    def _early_stopping_possible(self, pauli_idx: int, observable: PauliWord) -> bool:
        """Whether a node can be discarded (all reachable expectations vanish).

        Mirrors the criterion of Nemkov et al. (light cone): a qubit on which
        the observable carries an X/Y must be covered by an X/Y generator of
        some remaining rotation (rotations[0..pauli_idx]); otherwise that X/Y can
        never be rotated into a diagonal term and the whole node contributes
        zero.  Equivalently, the node survives iff every qubit is either I/Z in
        the observable or covered by the cumulative rotation X/Y support.
        """
        obs_iz = np.logical_not(observable.xy_mask)
        combined = np.logical_or(obs_iz, self.cumulative_xy[pauli_idx]).all()
        return not bool(combined)

    # Frequency / weight structure (NumPy, parameter independent)
    def _build_spectrum_structure(self) -> None:
        """Build, per root, the frequency vectors and the (n_freq, n_leaves)
        weight matrix ``W`` such that ``coeffs = W @ (terms * variational)``.
        """
        self.freqs_per_root: List[np.ndarray] = []
        self.weights_per_root: List[np.ndarray] = []
        d = len(self.features)

        for S, C, _ in self.leaf_arrays:
            n_leaves = S.shape[0]
            freq_to_col: Dict[tuple, np.ndarray] = defaultdict(
                lambda: np.zeros(n_leaves, dtype=np.complex128)
            )
            for leaf in range(n_leaves):
                # One expansion factor per *active* input column, each carrying
                # its feature axis and integer frequency scaling.  Per leaf a
                # column contributes at most one sin/cos factor (square-free),
                # but different columns of the same feature may carry different
                # scalings, so they are expanded individually and convolved
                # rather than aggregating counts (which would assume a common
                # unit scaling).
                col_factors: List[List[Tuple[int, int, float]]] = []
                half_exp = 0
                for axis, feat in enumerate(self.features):
                    for k in self.input_indices[feat]:
                        s = int(S[leaf, k])
                        c = int(C[leaf, k])
                        if s == 0 and c == 0:
                            continue
                        half_exp += s + c
                        w_k = int(self.input_scaling[k])
                        col_factors.append(
                            [
                                (axis, int(o) * w_k, wt)
                                for o, wt in self._binomial_terms(s, c)
                            ]
                        )
                half = 0.5**half_exp

                if d == 0:
                    freq_to_col[(0,)][leaf] += half
                    continue

                if not col_factors:
                    freq_to_col[(0,) * d][leaf] += half
                    continue

                for combo in itertools.product(*col_factors):
                    omega = [0] * d
                    weight = half
                    for axis, o, wt in combo:
                        omega[axis] += o
                        weight *= wt
                    freq_to_col[tuple(omega)][leaf] += weight

            if freq_to_col:
                omegas = sorted(freq_to_col.keys())
                W = np.stack([freq_to_col[o] for o in omegas])  # (n_freq, n_leaves)
                freqs = np.array(omegas, dtype=np.int64)  # (n_freq, d)
            else:
                freqs = np.zeros((1, max(d, 1)), dtype=np.int64)
                W = np.zeros((1, n_leaves), dtype=np.complex128)

            # Collapse to 1-D frequency array for the single-feature case.
            if freqs.shape[1] == 1:
                freqs = freqs[:, 0]
            self.freqs_per_root.append(freqs)
            # Keep W in NumPy complex128: its entries are dyadic rationals
            # (binomial weights x 0.5^k x i^m), which are exact in float64 --
            # this allows exact symbolic zero-tests in get_exact_support.
            self.weights_per_root.append(W)

    @staticmethod
    def _binomial_terms(s: int, c: int) -> List[Tuple[int, float]]:
        """Expand ``cos^c (i sin)^s`` in ``e^{i omega x}`` (without the 0.5 factor).

        Returns a list of ``(omega, weight)`` with
        ``omega = 2a + 2b - s - c`` and ``weight = C(s,a) C(c,b) (-1)^{s-a}``.
        """
        terms = []
        for a in range(s + 1):
            for b in range(c + 1):
                weight = math.comb(s, a) * math.comb(c, b) * (-1) ** (s - a)
                terms.append((2 * a + 2 * b - s - c, float(weight)))
        return terms

    # Vectorised numeric evaluation (JAX)
    @staticmethod
    def _safe_pow(base: jnp.ndarray, exp: jnp.ndarray) -> jnp.ndarray:
        """Elementwise ``base ** exp`` for real base and non-negative integer
        exponents, correct for negative bases (avoids ``log`` of negatives).

        Args:
            base: real array of shape ``(n,)``.
            exp: integer array of shape ``(n_leaves, n)``.
        """
        mag = jnp.abs(base)[None, :] ** exp
        sign = jnp.where(exp % 2 == 0, 1.0, jnp.sign(base)[None, :])
        return sign * mag

    _I_POW = None  # set lazily to jnp.array([1, 1j, -1, -1j])

    def _leaf_factors(
        self, S: np.ndarray, C: np.ndarray, columns: np.ndarray
    ) -> jnp.ndarray:
        """Per-leaf product ``prod_i cos(theta_i)^{C} (i sin(theta_i))^{S}`` over
        the given parameter ``columns`` (vectorised over leaves).
        """
        if FourierTree._I_POW is None:
            FourierTree._I_POW = jnp.array([1, 1j, -1, -1j])

        if S.shape[0] == 0:
            return jnp.zeros(0, dtype=jnp.complex64)

        theta = jnp.stack([self.parameters[i] for i in columns])
        S_sub = jnp.asarray(S[:, columns])
        C_sub = jnp.asarray(C[:, columns])

        cos_part = self._safe_pow(jnp.cos(theta), C_sub)
        sin_mag = self._safe_pow(jnp.sin(theta), S_sub)
        i_part = FourierTree._I_POW[S_sub % 4]
        return jnp.prod(cos_part * sin_mag * i_part, axis=1)

    def __call__(
        self,
        params: Optional[jnp.ndarray] = None,
        inputs: Optional[jnp.ndarray] = None,
        **kwargs,
    ) -> jnp.ndarray:
        """
        Evaluate the expectation value(s) of the model's observables via the
        sine-cosine tree (equivalent to the circuit expectation).

        Args:
            params (Optional[jnp.ndarray]): Model parameters. Defaults to the
                model's parameters.
            inputs (Optional[jnp.ndarray]): Inputs to the circuit. Defaults to 1.

        Returns:
            jnp.ndarray: Expectation value per observable (or their mean if
                ``force_mean`` is set).

        Raises:
            NotImplementedError: For execution types other than "expval" or when
                noise is requested.
        """
        params = (
            self.model._params_validation(params)
            if params is not None
            else self.model.params
        )
        inputs = (
            self.model._inputs_validation(inputs)
            if inputs is not None
            else self.model._inputs_validation(1.0)
        )

        if kwargs.get("execution_type", "expval") != "expval":
            raise NotImplementedError(
                f'Currently, only "expval" execution type is supported when '
                f"building FourierTree. Got {kwargs.get('execution_type', 'expval')}."
            )
        if kwargs.get("noise_params", None) is not None:
            raise NotImplementedError(
                "Currently, noise is not supported when building FourierTree."
            )

        # Re-derive the (canonical) parameter values for the requested inputs;
        # the tree structure (leaf arrays) is unchanged.
        operations, _ = self._build_canonical_tape(params, inputs)
        self.parameters = [
            jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations)
        ]

        self._ensure_structure()
        all_columns = np.arange(self.n_params, dtype=np.int64)
        results = []
        for S, C, terms in self.leaf_arrays:
            factors = self._leaf_factors(S, C, all_columns)
            results.append(jnp.real(jnp.sum(jnp.asarray(terms) * factors)))
        results = jnp.array(results)

        if kwargs.get("force_mean", False):
            return jnp.mean(results)
        return results

    def get_spectrum(
        self, force_mean: bool = False
    ) -> Tuple[List[jnp.ndarray], List[jnp.ndarray]]:
        """
        Compute the Fourier spectrum (coefficients and frequencies) of the tree.

        Args:
            force_mean (bool, optional): Average the coefficients over all
                observables (roots). Defaults to False.

        Returns:
            Tuple[List[jnp.ndarray], List[jnp.ndarray]]:
                - List of coefficients, one entry per observable (root).
                - List of corresponding frequencies, one entry per root.
                When ``force_mean`` is set, both lists have a single entry.
        """
        self._ensure_structure()
        per_root_coeffs: List[jnp.ndarray] = []
        for (S, C, terms), W in zip(self.leaf_arrays, self.weights_per_root):
            leaf_const = jnp.asarray(terms) * self._leaf_factors(
                S, C, self.var_positions
            )
            per_root_coeffs.append(jnp.asarray(W) @ leaf_const)

        return self._combine_roots(per_root_coeffs, self.freqs_per_root, force_mean)

    def get_exact_support(self, method: str = "tree") -> List[np.ndarray]:
        r"""Symbolically derive the exact frequency support (no sampling).

        A frequency :math:`\omega` belongs to the exact spectrum iff its
        coefficient :math:`c_\omega(\theta) = \sum_l W_{\omega l}\,
        \text{term}_l\, v_l(\theta)` is not identically zero in the
        variational parameters :math:`\theta`.

        Two methods are available:

        - ``"tree"`` (default, fully exact): enumerates the explicit tree
          leaves.  Because the branch index strictly decreases along every tree
          path, each parameter contributes **at most one** sine *or* cosine
          factor per leaf (:math:`S_{li}, C_{li} \in \{0, 1\}`).  Every
          variational leaf factor :math:`v_l` is therefore a *square-free*
          monomial over :math:`\{1, \cos\theta_i, i\sin\theta_i\}`, and
          monomials with distinct signatures are linearly independent functions
          (no :math:`\cos^2 + \sin^2` identities can arise without squares).
          Hence

          .. math::
              c_\omega \equiv 0 \iff \sum_{l \in g} W_{\omega l}\,\text{term}_l
              = 0 \quad \text{for every signature group } g.

          Since all involved quantities are dyadic rationals times
          :math:`\{\pm 1, \pm i\}`, the group sums are exact in float64 and the
          zero-test is exact.  The number of leaves can however grow
          exponentially with circuit depth.

        - ``"dp"`` (scalable): merges tree nodes with identical
          ``(rotation index, observable)`` — at most ``n_params * 4^n_qubits``
          states — and tracks the achievable input sine/cosine count pairs
          ``(s, c)`` per state.  The support is the union of the (exact)
          expansion supports of :math:`\cos^c x\, (i \sin x)^s` over all
          achievable pairs.  This is exact per tree path (including interior
          zero coefficients of the expansions), but unlike ``"tree"`` it cannot
          detect coefficients that cancel identically *across* paths with
          identical variational signatures (e.g. directly repeated encodings).
          It therefore yields a tight superset in such corner cases.
          Currently restricted to a single input feature.

        Args:
            method (str): ``"tree"`` (fully exact) or ``"dp"`` (scalable).

        Returns:
            List[np.ndarray]: For each observable (root), the frequency vectors
            with not-identically-zero coefficient — shape ``(n_freq,)`` for a
            single input feature, ``(n_freq, n_features)`` otherwise.
        """
        if method == "dp":
            return self._support_dp()
        if method != "tree":
            raise ValueError(f"Unknown method '{method}'. Use 'tree' or 'dp'.")

        self._ensure_structure()
        supports = []
        for (S, C, terms), W, freqs in zip(
            self.leaf_arrays, self.weights_per_root, self.freqs_per_root
        ):
            freqs = np.asarray(freqs)
            n_leaves = S.shape[0]
            if n_leaves == 0:
                supports.append(freqs[:0])
                continue

            # Group leaves by their variational sine/cosine signature.
            signature = np.hstack([S[:, self.var_positions], C[:, self.var_positions]])
            _, groups = np.unique(signature, axis=0, return_inverse=True)
            n_groups = int(groups.max()) + 1

            # Per-group sums of W[omega, l] * term_l, accumulated exactly.
            contrib = (W * terms[None, :]).T  # (n_leaves, n_freq)
            group_sums = np.zeros((n_groups, W.shape[0]), dtype=np.complex128)
            np.add.at(group_sums, groups, contrib)

            mask = (np.abs(group_sums) > 1e-12).any(axis=0)  # (n_freq,)
            supports.append(freqs[mask])
        return supports

    def _support_dp(self) -> List[np.ndarray]:
        """Merged-state dynamic program for the frequency support.

        Instead of enumerating all (worst-case exponentially many) tree paths,
        nodes are merged on ``(rotation index, bare observable)``.  Each state
        stores the set of achievable input ``(s, c)`` count pairs as a bitmask,
        so transitions are O(1) big-int operations.  See
        :meth:`get_exact_support` for semantics and limitations.
        """
        if len(self.features) != 1:
            raise NotImplementedError(
                "The 'dp' support method currently supports exactly one input "
                "feature; use method='tree' for multi-feature models."
            )

        if self.all_input_indices and np.any(
            self.input_scaling[self.all_input_indices] != 1
        ):
            raise NotImplementedError(
                "The 'dp' support method does not support non-unit input "
                "frequency scaling (it aggregates sin/cos counts and cannot "
                "represent per-gate scalings); use method='tree'."
            )

        n = self.n_qubits
        is_input = np.zeros(self.n_params, dtype=bool)
        is_input[self.all_input_indices] = True
        n_inp = int(is_input.sum())
        stride = n_inp + 1  # bit index for (s, c) is  s * stride + c

        def encode(word: PauliWord) -> Tuple[int, int]:
            x = z = 0
            for q in range(n):
                x |= int(word.x[q]) << q
                z |= int(word.z[q]) << q
            return x, z

        paulis = [encode(w) for w in self.pauli_words]
        cum_xy = []
        running = 0
        for xp, _ in paulis:
            running |= xp
            cum_xy.append(running)

        def parity(v: int) -> int:
            return bin(v).count("1") & 1

        def dp(idx: int, xo: int, zo: int, memo: dict) -> int:
            # Light-cone early stopping (cf. _early_stopping_possible).
            if idx >= 0 and (xo & ~cum_xy[idx]):
                return 0
            # Skip trailing rotations that commute with the observable.
            while idx >= 0:
                xp, zp = paulis[idx]
                if parity(xo & zp) ^ parity(zo & xp):
                    break
                idx -= 1
            else:  # leaf: counts (s=0, c=0) iff the observable is diagonal
                return 1 if xo == 0 else 0
            key = (idx, xo, zo)
            hit = memo.get(key)
            if hit is not None:
                return hit
            xp, zp = paulis[idx]
            cos_child = dp(idx - 1, xo, zo, memo)
            sin_child = dp(idx - 1, xo ^ xp, zo ^ zp, memo)
            if is_input[idx]:
                # Active input gate: cosine increments c, sine increments s.
                val = (cos_child << 1) | (sin_child << stride)
            else:
                val = cos_child | sin_child
            memo[key] = val
            return val

        # Recursion depth is bounded by the number of rotations.
        old_limit = sys.getrecursionlimit()
        sys.setrecursionlimit(max(old_limit, self.n_params + 1000))
        try:
            supports = []
            for obs in self.observable_words:
                memo: dict = {}
                xo, zo = encode(obs)
                mask = dp(self.n_params - 1, xo, zo, memo)
                freqs: set = set()
                while mask:
                    bit = mask & -mask
                    i = bit.bit_length() - 1
                    freqs |= self._expansion_support(i // stride, i % stride)
                    mask ^= bit
                supports.append(np.array(sorted(freqs), dtype=np.int64))
        finally:
            sys.setrecursionlimit(old_limit)
        return supports

    @staticmethod
    @lru_cache(maxsize=None)
    def _expansion_support(s: int, c: int) -> frozenset:
        r"""Frequencies with non-zero coefficient in :math:`\cos^c x (i\sin x)^s`.

        Computed exactly with integer arithmetic via the polynomial
        :math:`(t - 1)^s (t + 1)^c` (with :math:`t = e^{2ix}` up to a shift);
        interior coefficients can vanish, e.g. :math:`\cos x \sin x` only
        contains :math:`\pm 2`.
        """
        coeffs = [1]
        for _ in range(s):  # multiply by (t - 1)
            new = [0] * (len(coeffs) + 1)
            for i, a in enumerate(coeffs):
                new[i + 1] += a
                new[i] -= a
            coeffs = new
        for _ in range(c):  # multiply by (t + 1)
            new = [0] * (len(coeffs) + 1)
            for i, a in enumerate(coeffs):
                new[i + 1] += a
                new[i] += a
            coeffs = new
        m = s + c
        return frozenset(2 * k - m for k, a in enumerate(coeffs) if a != 0)

    def _combine_roots(
        self,
        per_root_coeffs: List[jnp.ndarray],
        per_root_freqs: List[np.ndarray],
        force_mean: bool,
    ) -> Tuple[List[jnp.ndarray], List[jnp.ndarray]]:
        """Assemble the per-root spectra, optionally averaging over roots."""
        if not force_mean:
            coefficients = [jnp.asarray(c) for c in per_root_coeffs]
            frequencies = [jnp.asarray(f) for f in per_root_freqs]
            return coefficients, frequencies

        # Average over roots on the union of all frequency vectors.
        accum: Dict[tuple, complex] = defaultdict(complex)
        for coeffs, freqs in zip(per_root_coeffs, per_root_freqs):
            freqs_np = np.asarray(freqs)
            for k in range(freqs_np.shape[0]):
                key = (
                    (int(freqs_np[k]),)
                    if freqs_np.ndim == 1
                    else tuple(int(v) for v in freqs_np[k])
                )
                accum[key] += complex(coeffs[k])
        n_roots = max(len(per_root_coeffs), 1)
        keys = sorted(accum.keys())
        mean_coeffs = jnp.array([accum[k] / n_roots for k in keys])
        freq_arr = np.array(keys, dtype=np.int64)
        if freq_arr.shape[1] == 1:
            freq_arr = freq_arr[:, 0]
        return [mean_coeffs], [jnp.asarray(freq_arr)]

__call__(params=None, inputs=None, **kwargs) #

Evaluate the expectation value(s) of the model's observables via the sine-cosine tree (equivalent to the circuit expectation).

Parameters:

Name Type Description Default
params Optional[ndarray]

Model parameters. Defaults to the model's parameters.

None
inputs Optional[ndarray]

Inputs to the circuit. Defaults to 1.

None

Returns:

Type Description
ndarray

jnp.ndarray: Expectation value per observable (or their mean if force_mean is set).

Raises:

Type Description
NotImplementedError

For execution types other than "expval" or when noise is requested.

Source code in qml_essentials/coefficients.py
def __call__(
    self,
    params: Optional[jnp.ndarray] = None,
    inputs: Optional[jnp.ndarray] = None,
    **kwargs,
) -> jnp.ndarray:
    """
    Evaluate the expectation value(s) of the model's observables via the
    sine-cosine tree (equivalent to the circuit expectation).

    Args:
        params (Optional[jnp.ndarray]): Model parameters. Defaults to the
            model's parameters.
        inputs (Optional[jnp.ndarray]): Inputs to the circuit. Defaults to 1.

    Returns:
        jnp.ndarray: Expectation value per observable (or their mean if
            ``force_mean`` is set).

    Raises:
        NotImplementedError: For execution types other than "expval" or when
            noise is requested.
    """
    params = (
        self.model._params_validation(params)
        if params is not None
        else self.model.params
    )
    inputs = (
        self.model._inputs_validation(inputs)
        if inputs is not None
        else self.model._inputs_validation(1.0)
    )

    if kwargs.get("execution_type", "expval") != "expval":
        raise NotImplementedError(
            f'Currently, only "expval" execution type is supported when '
            f"building FourierTree. Got {kwargs.get('execution_type', 'expval')}."
        )
    if kwargs.get("noise_params", None) is not None:
        raise NotImplementedError(
            "Currently, noise is not supported when building FourierTree."
        )

    # Re-derive the (canonical) parameter values for the requested inputs;
    # the tree structure (leaf arrays) is unchanged.
    operations, _ = self._build_canonical_tape(params, inputs)
    self.parameters = [
        jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations)
    ]

    self._ensure_structure()
    all_columns = np.arange(self.n_params, dtype=np.int64)
    results = []
    for S, C, terms in self.leaf_arrays:
        factors = self._leaf_factors(S, C, all_columns)
        results.append(jnp.real(jnp.sum(jnp.asarray(terms) * factors)))
    results = jnp.array(results)

    if kwargs.get("force_mean", False):
        return jnp.mean(results)
    return results

__init__(model) #

Tree initialisation, based on the Pauli-Clifford representation of a model.

Parameters:

Name Type Description Default
model Model

The Model, for which to build the tree.

required
Source code in qml_essentials/coefficients.py
def __init__(self, model: Model):
    """
    Tree initialisation, based on the Pauli-Clifford representation of a
    model.

    Args:
        model (Model): The Model, for which to build the tree.
    """
    self.model = model
    self.n_qubits = model.n_qubits

    # A single (de-batched) parameter set drives the whole tree.
    self._params = self._single_param_set(model.params)

    # Canonical Pauli-Clifford structure, recorded once at a fixed base
    # input.  The base value is irrelevant to the structure (it only sets
    # the rotation angles, not which Pauli words appear).
    base_inputs = np.ones(model.n_input_feat)
    operations, observables = self._build_canonical_tape(self._params, base_inputs)

    self.parameters = [
        jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations)
    ]
    self.n_params = len(self.parameters)

    # Pauli generators of the (canonical) rotations, as symbolic words.
    self.pauli_words: List[PauliWord] = [
        PauliWord.from_operation(op, self.n_qubits) for op in operations
    ]

    # Cumulative X/Y support of the rotations[0..k] (for light-cone early
    # stopping).  cumulative_xy[k] is True on every qubit touched by an X/Y
    # generator in any rotation up to index k.
    self.cumulative_xy: List[np.ndarray] = []
    running = np.zeros(self.n_qubits, dtype=bool)
    for pw in self.pauli_words:
        running = np.logical_or(running, pw.xy_mask)
        self.cumulative_xy.append(running.copy())

    # Observable Pauli words (one tree root each).
    self.observable_words: List[PauliWord] = [
        PauliWord.from_operation(obs, self.n_qubits) for obs in observables
    ]

    # Identify the input-encoding columns, their feature, and integer
    # frequency scaling directly from the tape (no per-gate tagging).  Sets
    # ``input_indices``, ``all_input_indices``, ``input_scaling``,
    # ``var_positions`` and ``features``.
    self._detect_inputs(base_inputs)

    # The explicit leaf structure is built lazily: for deep circuits the
    # number of tree paths explodes combinatorially, while the canonical
    # form above (and the merged-state support DP) remain cheap.
    self._structure_built = False

get_exact_support(method='tree') #

Symbolically derive the exact frequency support (no sampling).

A frequency :math:\omega belongs to the exact spectrum iff its coefficient :math:c_\omega(\theta) = \sum_l W_{\omega l}\, \text{term}_l\, v_l(\theta) is not identically zero in the variational parameters :math:\theta.

Two methods are available:

  • "tree" (default, fully exact): enumerates the explicit tree leaves. Because the branch index strictly decreases along every tree path, each parameter contributes at most one sine or cosine factor per leaf (:math:S_{li}, C_{li} \in \{0, 1\}). Every variational leaf factor :math:v_l is therefore a square-free monomial over :math:\{1, \cos\theta_i, i\sin\theta_i\}, and monomials with distinct signatures are linearly independent functions (no :math:\cos^2 + \sin^2 identities can arise without squares). Hence

.. math:: c_\omega \equiv 0 \iff \sum_{l \in g} W_{\omega l}\,\text{term}_l = 0 \quad \text{for every signature group } g.

Since all involved quantities are dyadic rationals times :math:\{\pm 1, \pm i\}, the group sums are exact in float64 and the zero-test is exact. The number of leaves can however grow exponentially with circuit depth.

  • "dp" (scalable): merges tree nodes with identical (rotation index, observable) — at most n_params * 4^n_qubits states — and tracks the achievable input sine/cosine count pairs (s, c) per state. The support is the union of the (exact) expansion supports of :math:\cos^c x\, (i \sin x)^s over all achievable pairs. This is exact per tree path (including interior zero coefficients of the expansions), but unlike "tree" it cannot detect coefficients that cancel identically across paths with identical variational signatures (e.g. directly repeated encodings). It therefore yields a tight superset in such corner cases. Currently restricted to a single input feature.

Parameters:

Name Type Description Default
method str

"tree" (fully exact) or "dp" (scalable).

'tree'

Returns:

Type Description
List[ndarray]

List[np.ndarray]: For each observable (root), the frequency vectors

List[ndarray]

with not-identically-zero coefficient — shape (n_freq,) for a

List[ndarray]

single input feature, (n_freq, n_features) otherwise.

Source code in qml_essentials/coefficients.py
def get_exact_support(self, method: str = "tree") -> List[np.ndarray]:
    r"""Symbolically derive the exact frequency support (no sampling).

    A frequency :math:`\omega` belongs to the exact spectrum iff its
    coefficient :math:`c_\omega(\theta) = \sum_l W_{\omega l}\,
    \text{term}_l\, v_l(\theta)` is not identically zero in the
    variational parameters :math:`\theta`.

    Two methods are available:

    - ``"tree"`` (default, fully exact): enumerates the explicit tree
      leaves.  Because the branch index strictly decreases along every tree
      path, each parameter contributes **at most one** sine *or* cosine
      factor per leaf (:math:`S_{li}, C_{li} \in \{0, 1\}`).  Every
      variational leaf factor :math:`v_l` is therefore a *square-free*
      monomial over :math:`\{1, \cos\theta_i, i\sin\theta_i\}`, and
      monomials with distinct signatures are linearly independent functions
      (no :math:`\cos^2 + \sin^2` identities can arise without squares).
      Hence

      .. math::
          c_\omega \equiv 0 \iff \sum_{l \in g} W_{\omega l}\,\text{term}_l
          = 0 \quad \text{for every signature group } g.

      Since all involved quantities are dyadic rationals times
      :math:`\{\pm 1, \pm i\}`, the group sums are exact in float64 and the
      zero-test is exact.  The number of leaves can however grow
      exponentially with circuit depth.

    - ``"dp"`` (scalable): merges tree nodes with identical
      ``(rotation index, observable)`` — at most ``n_params * 4^n_qubits``
      states — and tracks the achievable input sine/cosine count pairs
      ``(s, c)`` per state.  The support is the union of the (exact)
      expansion supports of :math:`\cos^c x\, (i \sin x)^s` over all
      achievable pairs.  This is exact per tree path (including interior
      zero coefficients of the expansions), but unlike ``"tree"`` it cannot
      detect coefficients that cancel identically *across* paths with
      identical variational signatures (e.g. directly repeated encodings).
      It therefore yields a tight superset in such corner cases.
      Currently restricted to a single input feature.

    Args:
        method (str): ``"tree"`` (fully exact) or ``"dp"`` (scalable).

    Returns:
        List[np.ndarray]: For each observable (root), the frequency vectors
        with not-identically-zero coefficient — shape ``(n_freq,)`` for a
        single input feature, ``(n_freq, n_features)`` otherwise.
    """
    if method == "dp":
        return self._support_dp()
    if method != "tree":
        raise ValueError(f"Unknown method '{method}'. Use 'tree' or 'dp'.")

    self._ensure_structure()
    supports = []
    for (S, C, terms), W, freqs in zip(
        self.leaf_arrays, self.weights_per_root, self.freqs_per_root
    ):
        freqs = np.asarray(freqs)
        n_leaves = S.shape[0]
        if n_leaves == 0:
            supports.append(freqs[:0])
            continue

        # Group leaves by their variational sine/cosine signature.
        signature = np.hstack([S[:, self.var_positions], C[:, self.var_positions]])
        _, groups = np.unique(signature, axis=0, return_inverse=True)
        n_groups = int(groups.max()) + 1

        # Per-group sums of W[omega, l] * term_l, accumulated exactly.
        contrib = (W * terms[None, :]).T  # (n_leaves, n_freq)
        group_sums = np.zeros((n_groups, W.shape[0]), dtype=np.complex128)
        np.add.at(group_sums, groups, contrib)

        mask = (np.abs(group_sums) > 1e-12).any(axis=0)  # (n_freq,)
        supports.append(freqs[mask])
    return supports

get_spectrum(force_mean=False) #

Compute the Fourier spectrum (coefficients and frequencies) of the tree.

Parameters:

Name Type Description Default
force_mean bool

Average the coefficients over all observables (roots). Defaults to False.

False

Returns:

Type Description
Tuple[List[ndarray], List[ndarray]]

Tuple[List[jnp.ndarray], List[jnp.ndarray]]: - List of coefficients, one entry per observable (root). - List of corresponding frequencies, one entry per root. When force_mean is set, both lists have a single entry.

Source code in qml_essentials/coefficients.py
def get_spectrum(
    self, force_mean: bool = False
) -> Tuple[List[jnp.ndarray], List[jnp.ndarray]]:
    """
    Compute the Fourier spectrum (coefficients and frequencies) of the tree.

    Args:
        force_mean (bool, optional): Average the coefficients over all
            observables (roots). Defaults to False.

    Returns:
        Tuple[List[jnp.ndarray], List[jnp.ndarray]]:
            - List of coefficients, one entry per observable (root).
            - List of corresponding frequencies, one entry per root.
            When ``force_mean`` is set, both lists have a single entry.
    """
    self._ensure_structure()
    per_root_coeffs: List[jnp.ndarray] = []
    for (S, C, terms), W in zip(self.leaf_arrays, self.weights_per_root):
        leaf_const = jnp.asarray(terms) * self._leaf_factors(
            S, C, self.var_positions
        )
        per_root_coeffs.append(jnp.asarray(W) @ leaf_const)

    return self._combine_roots(per_root_coeffs, self.freqs_per_root, force_mean)

Fourier Coefficient Correlation#

from qml_essentials.coefficients import FCC
Source code in qml_essentials/coefficients.py
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
class FCC:
    @classmethod
    def get_fcc(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[random.PRNGKey] = None,
        method: Optional[str] = "pearson",
        scale: Optional[bool] = False,
        weight: Optional[bool] = False,
        trim_redundant: Optional[bool] = True,
        **kwargs,
    ) -> float:
        """
        Shortcut method to get just the FCC.
        This includes
        1. What is done in `get_fourier_fingerprint`:
            1. Calculating the coefficients (using `n_samples`)
            2. Correlating the result from 1) using `method`
            3. Weighting the correlation matrix (if `weight` is True)
            4. Remove redundancies
        2. What is done in `calculate_fcc`:
            1. Absolute of the fingerprint
            2. Average

        Args:
            model (Model): The QFM model
            n_samples (int): Number of samples to calculate average of coefficients
            random_key (Optional[random.PRNGKey]): JAX random key for parameter
                initialization. If None, uses the model's internal random key.
            method (Optional[str], optional): Correlation method. Supported values are
                "pearson", "complex_pearson", "spearman", and "covariance".
                Defaults to "pearson".
            scale (Optional[bool], optional): Whether to scale the number of samples.
                Defaults to False.
            weight (Optional[bool], optional): Whether to weight the correlation matrix.
                Defaults to False.
            trim_redundant (Optional[bool], optional): Whether to remove redundant
                correlations. Defaults to False.
            **kwargs (Any): Additional keyword arguments for the model function.

        Returns:
            float: The FCC
        """

        # Memory-efficient fast path
        if trim_redundant and not weight:
            _, coeffs, freqs = cls._calculate_coefficients(
                model, n_samples, random_key, scale, **kwargs
            )
            pos_idx = cls._calculate_mask(freqs)
            coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1])
            coeffs_sub = coeffs_flat[pos_idx]

            fp = cls._correlate(coeffs_sub.transpose(), method=method)
            abs_fp = jnp.abs(fp)
            diag = jnp.abs(jnp.diagonal(fp))

            total_sum = jnp.nansum(abs_fp)
            total_count = jnp.sum(jnp.isfinite(abs_fp))
            diag_sum = jnp.nansum(diag)
            diag_count = jnp.sum(jnp.isfinite(diag))

            lower_sum = (total_sum - diag_sum) / 2.0
            lower_count = (total_count - diag_count) / 2.0
            return lower_sum / lower_count

        fourier_fingerprint, _ = cls.get_fourier_fingerprint(
            model,
            n_samples,
            random_key,
            method,
            scale,
            weight,
            trim_redundant=trim_redundant,
            **kwargs,
        )

        return cls.calculate_fcc(fourier_fingerprint)

    @classmethod
    def get_fourier_fingerprint(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[random.PRNGKey] = None,
        method: Optional[str] = "pearson",
        scale: Optional[bool] = False,
        weight: Optional[bool] = False,
        trim_redundant: Optional[bool] = True,
        nan_to_one: Optional[bool] = False,
        **kwargs: Any,
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Shortcut method to get just the fourier fingerprint.
        This includes
        1. Calculating the coefficients (using `n_samples`)
        2. Correlating the result from 1) using `method`
        3. Weighting the correlation matrix (if `weight` is True)
        4. Remove redundancies (if `trim_redundant` is True)

        Args:
            model (Model): The QFM model
            n_samples (int): Number of samples to calculate average of coefficients
            random_key (Optional[random.PRNGKey]): JAX random key for parameter
                initialization. If None, uses the model's internal random key.
            method (Optional[str], optional): Correlation method. Supported values are
                "pearson", "complex_pearson", "spearman", and "covariance".
                Defaults to "pearson".
            scale (Optional[bool], optional): Whether to scale the number of samples.
                Defaults to False.
            weight (Optional[bool], optional): Whether to weight the correlation matrix.
                Defaults to False.
            trim_redundant (Optional[bool], optional): Whether to remove redundant
                correlations. Defaults to True.
            nan_to_one (Optional[bool], optional): Whether to set nan to 1.
                Defaults to False.
            **kwargs: Additional keyword arguments for the model function.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]: The fourier fingerprint and the
            corresponding frequency indices. If `trim_redundant` is True the
            frequencies are returned as a `(row_freqs, col_freqs)` tuple that
            labels the two (redundancy-trimmed) matrix axes; otherwise the
            full frequency vector is returned.
        """
        _, coeffs, freqs = cls._calculate_coefficients(
            model, n_samples, random_key, scale, **kwargs
        )

        # Memory-efficient fast path
        if trim_redundant and not weight:
            pos_idx = cls._calculate_mask(freqs)
            pos_freqs = cls._flat_frequencies(freqs)[pos_idx]

            # Flatten all frequency axes; the last axis is the sample
            # axis. `_calculate_mask` returns flat indices in C order,
            # matching this reshape.
            coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1])
            coeffs_sub = coeffs_flat[pos_idx]

            fourier_fingerprint = cls._correlate(coeffs_sub.transpose(), method=method)

            if nan_to_one:
                fourier_fingerprint = jnp.where(
                    jnp.isnan(fourier_fingerprint), 1.0, fourier_fingerprint
                )

            M = fourier_fingerprint.shape[0]
            lower_tri_mask = jnp.tri(M, k=-1, dtype=bool)
            fourier_fingerprint = jnp.where(
                lower_tri_mask, fourier_fingerprint, jnp.nan
            )

            row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1)
            col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0)
            fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask]

            return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask])

        fourier_fingerprint = cls._correlate(coeffs.transpose(), method=method)

        if nan_to_one:
            # set nan to 1
            fourier_fingerprint[jnp.isnan(fourier_fingerprint)] = 1.0

        # perform weighting if requested
        fourier_fingerprint = (
            cls._weighting_mean(fourier_fingerprint, coeffs)
            if weight
            else fourier_fingerprint
        )

        if trim_redundant:
            pos_idx = cls._calculate_mask(freqs)
            pos_freqs = cls._flat_frequencies(freqs)[pos_idx]

            # restrict to the positive-frequency sub-block (M x M with
            # M = number of non-negative flat-frequencies) instead of
            # building a full N x N mask. This avoids the O(N^2) float
            fourier_fingerprint = fourier_fingerprint[pos_idx][:, pos_idx]

            # keep only the strict lower triangle; the rest -> nan
            M = fourier_fingerprint.shape[0]
            lower_tri_mask = jnp.tri(M, k=-1, dtype=bool)
            fourier_fingerprint = jnp.where(
                lower_tri_mask, fourier_fingerprint, jnp.nan
            )

            row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1)
            col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0)

            fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask]

            return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask])

        return fourier_fingerprint, freqs

    @classmethod
    def calculate_fcc(
        cls,
        fourier_fingerprint: jnp.ndarray,
    ) -> float:
        """
        Method to calculate the FCC based on an existing correlation matrix.
        Calculate absolute and then the average over this matrix.
        The Fingerprint can be obtained via `get_fourier_fingerprint`

        Args:
            fourier_fingerprint (jnp.ndarray): Correlation matrix of coefficients
        Returns:
            float: The FCC
        """
        # apply the mask on the fingerprint
        return jnp.nanmean(jnp.abs(fourier_fingerprint))

    @classmethod
    def _calculate_mask(cls, freqs: jnp.ndarray) -> jnp.ndarray:
        """
        Determine the flat indices of the Fourier correlation matrix
        that lie on a non-negative-frequency row/column. Together with
        the strict-lower-triangle condition (handled by the caller),
        these indices select the entries of the correlation matrix
        that survive the redundancy filter applied in
        `get_fourier_fingerprint`:

        - rows/columns whose flat frequency component is negative are
          discarded (they are the complex-conjugate redundancies of
          their positive counterparts);
        - of the remaining positive-frequency sub-block, only the
          strict lower triangle is kept (the upper triangle, including
          the diagonal, contains either duplicates from symmetry or
          self-correlations).

        Args:
            freqs (jnp.ndarray): Array of frequencies. Either a 1-D
                vector (single input feature) or a 2-D array of shape
                ``(n_input_feat, K)`` whose rows are the per-axis
                frequency vectors.

        Returns:
            jnp.ndarray: 1-D int array of flat indices selecting the
                non-negative-frequency rows/cols of the fingerprint.
        """
        freqs_arr = jnp.asarray(freqs)

        if freqs_arr.ndim == 1:
            pos_flat = freqs_arr >= 0
        else:
            # N-D case: build the per-axis non-negativity masks and
            # combine them via broadcasting (no float `jnp.outer`!),
            # then flatten to match the row-major flattening used by
            # the upstream coefficient/correlation pipeline.
            axes_pos = [freqs_arr[i] >= 0 for i in range(freqs_arr.shape[0])]
            expanded = []
            n_axes = len(axes_pos)
            for i, p in enumerate(axes_pos):
                shape = [1] * n_axes
                shape[i] = p.shape[0]
                expanded.append(p.reshape(shape))
            nd_pos = reduce(jnp.logical_and, expanded)
            pos_flat = nd_pos.flatten()

        return jnp.where(pos_flat)[0]

    @classmethod
    def _flat_frequencies(cls, freqs: jnp.ndarray) -> jnp.ndarray:
        """
        Build the per-coefficient flat frequency labels in the same
        C-order used to flatten the coefficient/correlation pipeline, so
        they can be indexed by the flat indices from `_calculate_mask`.

        Args:
            freqs (jnp.ndarray): Either a 1-D vector (single input feature)
                or a ``(n_input_feat, K)`` stack / list of per-axis frequency
                vectors (multi-dim input).

        Returns:
            jnp.ndarray: 1-D frequency vector (single input feature) or a
                ``(N, n_input_feat)`` array of per-coefficient frequency
                tuples (multi-dim input).
        """
        fa = jnp.asarray(freqs)
        if fa.ndim == 1:
            return fa
        # Multi-dim: per-axis vectors -> flat grid of frequency tuples in the
        # same C-order used by `_calculate_mask` and the coefficient reshape.
        grids = jnp.meshgrid(*[fa[i] for i in range(fa.shape[0])], indexing="ij")
        return jnp.stack(grids, axis=-1).reshape(-1, fa.shape[0])

    @classmethod
    def _calculate_coefficients(
        cls,
        model: Model,
        n_samples: int,
        random_key: Optional[random.PRNGKey] = None,
        scale: bool = False,
        **kwargs: Any,
    ) -> Tuple[jnp.ndarray, jnp.ndarray]:
        """
        Calculates the Fourier coefficients of a given model
        using `n_samples`.
        Optionally, `noise_params` can be passed to perform noisy simulation.

        Args:
            model (Model): The QFM model
            n_samples (int): Number of samples to calculate average of coefficients
            random_key (Optional[random.PRNGKey]): JAX random key for parameter
                initialization. If None, uses the model's internal random key.
            scale (bool, optional): Whether to scale the number of samples.
                Defaults to False.
            **kwargs: Additional keyword arguments for the model function.

        Returns:
            Tuple[jnp.ndarray, jnp.ndarray]: Parameters and Coefficients of size NxK
        """
        if n_samples > 0:
            if scale:
                total_samples = int(
                    jnp.power(2, model.n_qubits) * n_samples * model.n_input_feat
                )
                log.info(f"Using {total_samples} samples.")
            else:
                total_samples = n_samples
            model.initialize_params(random_key, repeat=total_samples)
        else:
            total_samples = 1

        coeffs, freqs = Coefficients.get_spectrum(
            model, shift=True, trim=True, **kwargs
        )

        return model.params, coeffs, freqs

    @classmethod
    def _correlate(cls, mat: jnp.ndarray, method: str = "pearson") -> jnp.ndarray:
        """
        Correlates two arrays using `method`.
        Currently, `pearson`, `complex_pearson`, `spearman`, and `covariance`
        are supported.

        Args:
            mat (jnp.ndarray): Array of shape (N, K)
            method (str, optional): Correlation method. Defaults to "pearson".

        Raises:
            ValueError: If the method is not supported.

        Returns:
            jnp.ndarray: Correlation matrix of `a` and `b`.
        """
        assert len(mat.shape) >= 2, "Input matrix must have at least 2 dimensions"

        # Note that for the general n-D case, we have to flatten along
        # the first axis (last one is batch).
        # Note that the order here is important so we can easily filter out
        # negative coefficients later.
        # Consider the following example: [[1,2,3],[4,5,6],[7,8,9]]
        # we want to get [1, 4, 7, 2, 5, 8, 3, 6, 9]
        # such that after correlation, all positive indexed coefficients
        # will be in the bottom right quadrant
        if method == "pearson":
            result = cls._pearson(mat.reshape(mat.shape[0], -1))
            # result = cls._pearson(mat.reshape(mat.shape[-1], -1, order="F"))
        elif method == "complex_pearson":
            result = cls._complex_pearson(mat.reshape(mat.shape[0], -1))
        elif method == "spearman":
            result = cls._spearman(mat.reshape(mat.shape[0], -1))
            # result = cls._spearman(mat.reshape(mat.shape[-1], -1, order="F"))
        elif method == "covariance":
            result = cls._covariance(mat.reshape(mat.shape[0], -1))
        else:
            raise ValueError(
                f"Unknown correlation method: {method}. Must be 'pearson', \
                             'complex_pearson', 'spearman' or 'covariance'."
            )

        return result

    @classmethod
    def _covariance(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray:
        """
        Compute the Hermitian sample covariance between columns of `mat`,
        permitting missing values (NaN or ±Inf).

        For each pair (i, j) the covariance is computed over the rows that are
        finite in both columns, as
        sum(conj(x_i - mean_i) * (x_j - mean_j)) / (nobs - 1),
        so it computes `X.conj().T @ X`.
        Real input collapses to the ordinary real sample covariance; complex
        input yields a complex matrix whose magnitude and angle carry the
        covariance strength and relative phase.


        Args:
            mat : array_like, shape (N, K)
                Input data.
            minp : int, optional
                Minimum number of paired observations required to form a
                covariance. If the number of valid pairs for (i, j) is < minp,
                the result is NaN.

        Returns:
            cov : ndarray, shape (K, K)
                Sample covariance matrix.
        """
        mat = jnp.asarray(mat)
        real_dtype = jnp.asarray(mat.real).dtype

        mask = jnp.isfinite(mat)
        fmask = mask.astype(real_dtype)
        safe = jnp.where(mask, mat, 0.0)

        nobs = fmask.T @ fmask
        nobs_safe = jnp.where(nobs > 0, nobs, 1.0)

        sum_x = safe.T @ fmask
        sum_y = fmask.T @ safe

        masked = safe * fmask
        sum_conj_xy = jnp.conj(masked).T @ masked

        sxy = sum_conj_xy - (jnp.conj(sum_x) * sum_y) / nobs_safe

        denom = jnp.where(nobs > 1, nobs - 1, jnp.nan)
        result = sxy / denom

        result = jnp.where(nobs < minp, jnp.nan, result)

        return result

    @classmethod
    def _complex_pearson(
        cls, mat: jnp.ndarray, minp: Optional[int] = 1
    ) -> jnp.ndarray:
        """
        Compute the complex Pearson correlation between columns of `mat`,
        permitting missing values (NaN or ±Inf).

        This uses the Hermitian normalized covariance
        sum(conj(x_i - mean_i) * (x_j - mean_j)) /
        sqrt(sum(abs(x_i - mean_i)**2) * sum(abs(x_j - mean_j)**2)).
        Consequently, if column j is exp(1j * phi) times column i, then
        abs(corr[i, j]) is 1 and angle(corr[i, j]) is phi.

        Args:
            mat : array_like, shape (N, K)
                Input data.
            minp : int, optional
                Minimum number of paired observations required to form a correlation.
                If the number of valid pairs for (i, j) is < minp, the result is NaN.

        Returns:
            corr : ndarray, shape (K, K)
                Complex Pearson correlation matrix.
        """
        mat = jnp.asarray(mat)
        real_dtype = jnp.asarray(mat.real).dtype

        mask = jnp.isfinite(mat)
        fmask = mask.astype(real_dtype)
        safe = jnp.where(mask, mat, 0.0)

        nobs = fmask.T @ fmask
        nobs_safe = jnp.where(nobs > 0, nobs, 1.0)

        sum_x = safe.T @ fmask
        sum_y = fmask.T @ safe

        masked = safe * fmask
        sum_conj_xy = jnp.conj(masked).T @ masked

        safe_abs_sq = jnp.abs(safe) ** 2
        sum_abs_x2 = safe_abs_sq.T @ fmask
        sum_abs_y2 = fmask.T @ safe_abs_sq

        ssx = sum_abs_x2 - jnp.abs(sum_x) ** 2 / nobs_safe
        ssy = sum_abs_y2 - jnp.abs(sum_y) ** 2 / nobs_safe
        sxy = sum_conj_xy - (jnp.conj(sum_x) * sum_y) / nobs_safe

        denom = jnp.sqrt(ssx * ssy)
        result = jnp.where(denom > 0, sxy / denom, jnp.nan)
        magnitude = jnp.abs(result)
        result = jnp.where(magnitude > 1.0, result / magnitude, result)

        result = jnp.where(nobs < minp, jnp.nan, result)

        return result

    @classmethod
    def _pearson(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray:
        """
        Compute Pearson correlation between columns of `mat`,
        permitting missing values (NaN or ±Inf).

        The Pearson correlation is the normalized covariance,
        corr[i, j] = cov[i, j] / sqrt(cov[i, i] * cov[j, j]),
        so it is obtained by normalizing `_covariance` by the per-column
        standard deviations.

        If the input is complex, real and imaginary parts are stacked along
        the sample axis so that both components contribute to the correlation
        without discarding information.

        Args:
            mat : array_like, shape (N, K)
                Input data.
            minp : int, optional
                Minimum number of paired observations required to form a correlation.
                If the number of valid pairs for (i, j) is < minp, the result is NaN.

        Returns:
            corr : ndarray, shape (K, K)
                Pearson correlation matrix.
        """
        # Preserve complex information by splitting into real / imag samples.
        # After stacking the data is real, so the Hermitian `_covariance`
        # reduces to the ordinary real sample covariance.
        if jnp.iscomplexobj(mat):
            mat = jnp.concatenate([mat.real, mat.imag], axis=0)

        cov = cls._covariance(mat, minp=minp)

        # corr[i, j] = cov[i, j] / (std_i * std_j) with std_i = sqrt(cov[i, i])
        std = jnp.sqrt(jnp.diagonal(cov))
        denom = std[:, None] * std[None, :]
        result = jnp.where(denom > 0, cov / denom, jnp.nan)

        # clip numerical drift to [-1, 1]
        result = jnp.clip(jnp.real(result), -1.0, 1.0)

        return result

    @classmethod
    def _spearman(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray:
        """
        Based on Pandas correlation method as implemented here:
        https://github.com/pandas-dev/pandas/blob/main/pandas/_libs/algos.pyx

        Compute Spearman correlation between columns of `mat`,
        permitting missing values (NaN or ±Inf).

        If the input is complex, real and imaginary parts are stacked along
        the sample axis so that both components contribute to the correlation
        without discarding information.

        Args:
            mat : array_like, shape (N, K)
                Input data.
            minp : int, optional
                Minimum number of paired observations required to form a correlation.
                If the number of valid pairs for (i, j) is < minp, the result is NaN.

        Returns:
            corr : ndarray, shape (K, K)
                Spearman correlation matrix.
        """
        # Preserve complex information by splitting into real / imag samples
        if jnp.iscomplexobj(mat):
            mat = jnp.concatenate([mat.real, mat.imag], axis=0)

        mat = jnp.asarray(mat)
        N, K = mat.shape

        # trivial all-NaN answer if too few rows
        if N < minp:
            return jnp.full((K, K), jnp.nan)

        # mask of finite entries
        mask = jnp.isfinite(mat)  # shape (N, K), dtype=bool

        # precompute ranks column-wise ignoring NaNs
        ranks = np.full((N, K), np.nan)
        for j in range(K):
            valid = mask[:, j]
            if valid.any():
                ranks[valid, j] = rankdata(mat[valid, j], method="average")

        ranks = jnp.asarray(ranks)

        # Vectorised Pearson on the ranks
        # Replace NaN ranks with 0; use mask to track validity.
        rank_mask = jnp.isfinite(ranks)
        safe_ranks = jnp.where(rank_mask, ranks, 0.0)

        # Pairwise valid-observation counts  (K, K)
        fmask = rank_mask.astype(ranks.dtype)
        nobs = fmask.T @ fmask

        # Pairwise sums over mutually-valid rows
        sum_x = safe_ranks.T @ fmask  # (K, K)
        sum_y = fmask.T @ safe_ranks  # (K, K)

        # Pairwise products
        masked_ranks = safe_ranks * fmask  # same as safe_ranks
        sum_xy = masked_ranks.T @ masked_ranks  # (K, K)

        safe_sq = safe_ranks**2
        sum_x2 = safe_sq.T @ fmask  # (K, K)
        sum_y2 = fmask.T @ safe_sq  # (K, K)

        nobs_safe = jnp.where(nobs > 0, nobs, 1.0)
        ssx = sum_x2 - sum_x**2 / nobs_safe
        ssy = sum_y2 - sum_y**2 / nobs_safe
        sxy = sum_xy - (sum_x * sum_y) / nobs_safe

        denom = jnp.sqrt(ssx * ssy)
        result = jnp.where(denom > 0, sxy / denom, jnp.nan)
        result = jnp.clip(result, -1.0, 1.0)

        # Enforce minp
        result = jnp.where(nobs < minp, jnp.nan, result)

        return result

    @classmethod
    def _weighting_linear(cls, fourier_fingerprint: jnp.ndarray) -> jnp.ndarray:
        """
        Performs weighting on the given correlation matrix.
        Here, low-frequent coefficients are weighted more heavily.

        Args:
            fourier_fingerprint (jnp.ndarray): Correlation matrix
        """
        assert (
            fourier_fingerprint.shape[0] % 2 != 0
            and fourier_fingerprint.shape[1] % 2 != 0
        ), (
            "Correlation matrix must have odd dimensions. \
            Hint: use `trim` argument when calling `get_spectrum`."
        )
        assert fourier_fingerprint.shape[0] == fourier_fingerprint.shape[1], (
            "Correlation matrix must be square."
        )

        # The weight matrix produced by the previous quadrant-mirror
        # construction has a closed form: it is a "tent" sum along the
        # two axes. Concretely, with N = fourier_fingerprint.shape[0]
        # (odd) and center = N // 2,
        #     W[i, j] = u[i] + u[j]
        # where u[k] = (center - |k - center|) / (2 * center)
        # is a triangular weighting peaking at the centre (the zero
        # frequency) and decaying linearly to 0 at the spectrum edges.
        N = fourier_fingerprint.shape[0]
        center = N // 2
        k = jnp.arange(N)
        u = (center - jnp.abs(k - center)) / (2 * center)

        return fourier_fingerprint * (u[:, None] + u[None, :])

    @classmethod
    def _weighting_mean(
        cls, fourier_fingerprint: jnp.ndarray, coeffs: jnp.ndarray
    ) -> jnp.ndarray:
        """
        Performs weighting on the given correlation matrix.
        Here, we use the product of the mean of the coefficients as weights.
        This suppresses correlations where the mean of the coefficients is near zero.

        Args:
            fourier_fingerprint (jnp.ndarray): Correlation matrix
            coeffs (jnp.ndarray): Fourier coefficients
        """
        assert fourier_fingerprint.shape[0] == fourier_fingerprint.shape[1], (
            "Correlation matrix must be square."
        )
        assert len(coeffs.shape) >= 2, (
            "Coefficient matrix must contain coefficient axes and a sample axis."
        )

        coefficient_means = jnp.abs(jnp.mean(coeffs, axis=-1))
        coefficient_means = coefficient_means.T.reshape(-1)

        assert fourier_fingerprint.shape[0] == coefficient_means.shape[0], (
            "Correlation matrix size must match the number of Fourier coefficients."
        )

        # Apply the rank-1 weight w[i] * w[j] via broadcasting instead
        # of materialising an explicit `jnp.outer` N x N intermediate.
        return (
            fourier_fingerprint
            * coefficient_means[:, None]
            * coefficient_means[None, :]
        )

calculate_fcc(fourier_fingerprint) classmethod #

Method to calculate the FCC based on an existing correlation matrix. Calculate absolute and then the average over this matrix. The Fingerprint can be obtained via get_fourier_fingerprint

Parameters:

Name Type Description Default
fourier_fingerprint ndarray

Correlation matrix of coefficients

required

Returns: float: The FCC

Source code in qml_essentials/coefficients.py
@classmethod
def calculate_fcc(
    cls,
    fourier_fingerprint: jnp.ndarray,
) -> float:
    """
    Method to calculate the FCC based on an existing correlation matrix.
    Calculate absolute and then the average over this matrix.
    The Fingerprint can be obtained via `get_fourier_fingerprint`

    Args:
        fourier_fingerprint (jnp.ndarray): Correlation matrix of coefficients
    Returns:
        float: The FCC
    """
    # apply the mask on the fingerprint
    return jnp.nanmean(jnp.abs(fourier_fingerprint))

get_fcc(model, n_samples, random_key=None, method='pearson', scale=False, weight=False, trim_redundant=True, **kwargs) classmethod #

Shortcut method to get just the FCC. This includes 1. What is done in get_fourier_fingerprint: 1. Calculating the coefficients (using n_samples) 2. Correlating the result from 1) using method 3. Weighting the correlation matrix (if weight is True) 4. Remove redundancies 2. What is done in calculate_fcc: 1. Absolute of the fingerprint 2. Average

Parameters:

Name Type Description Default
model Model

The QFM model

required
n_samples int

Number of samples to calculate average of coefficients

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
method Optional[str]

Correlation method. Supported values are "pearson", "complex_pearson", "spearman", and "covariance". Defaults to "pearson".

'pearson'
scale Optional[bool]

Whether to scale the number of samples. Defaults to False.

False
weight Optional[bool]

Whether to weight the correlation matrix. Defaults to False.

False
trim_redundant Optional[bool]

Whether to remove redundant correlations. Defaults to False.

True
**kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Name Type Description
float float

The FCC

Source code in qml_essentials/coefficients.py
@classmethod
def get_fcc(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[random.PRNGKey] = None,
    method: Optional[str] = "pearson",
    scale: Optional[bool] = False,
    weight: Optional[bool] = False,
    trim_redundant: Optional[bool] = True,
    **kwargs,
) -> float:
    """
    Shortcut method to get just the FCC.
    This includes
    1. What is done in `get_fourier_fingerprint`:
        1. Calculating the coefficients (using `n_samples`)
        2. Correlating the result from 1) using `method`
        3. Weighting the correlation matrix (if `weight` is True)
        4. Remove redundancies
    2. What is done in `calculate_fcc`:
        1. Absolute of the fingerprint
        2. Average

    Args:
        model (Model): The QFM model
        n_samples (int): Number of samples to calculate average of coefficients
        random_key (Optional[random.PRNGKey]): JAX random key for parameter
            initialization. If None, uses the model's internal random key.
        method (Optional[str], optional): Correlation method. Supported values are
            "pearson", "complex_pearson", "spearman", and "covariance".
            Defaults to "pearson".
        scale (Optional[bool], optional): Whether to scale the number of samples.
            Defaults to False.
        weight (Optional[bool], optional): Whether to weight the correlation matrix.
            Defaults to False.
        trim_redundant (Optional[bool], optional): Whether to remove redundant
            correlations. Defaults to False.
        **kwargs (Any): Additional keyword arguments for the model function.

    Returns:
        float: The FCC
    """

    # Memory-efficient fast path
    if trim_redundant and not weight:
        _, coeffs, freqs = cls._calculate_coefficients(
            model, n_samples, random_key, scale, **kwargs
        )
        pos_idx = cls._calculate_mask(freqs)
        coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1])
        coeffs_sub = coeffs_flat[pos_idx]

        fp = cls._correlate(coeffs_sub.transpose(), method=method)
        abs_fp = jnp.abs(fp)
        diag = jnp.abs(jnp.diagonal(fp))

        total_sum = jnp.nansum(abs_fp)
        total_count = jnp.sum(jnp.isfinite(abs_fp))
        diag_sum = jnp.nansum(diag)
        diag_count = jnp.sum(jnp.isfinite(diag))

        lower_sum = (total_sum - diag_sum) / 2.0
        lower_count = (total_count - diag_count) / 2.0
        return lower_sum / lower_count

    fourier_fingerprint, _ = cls.get_fourier_fingerprint(
        model,
        n_samples,
        random_key,
        method,
        scale,
        weight,
        trim_redundant=trim_redundant,
        **kwargs,
    )

    return cls.calculate_fcc(fourier_fingerprint)

get_fourier_fingerprint(model, n_samples, random_key=None, method='pearson', scale=False, weight=False, trim_redundant=True, nan_to_one=False, **kwargs) classmethod #

Shortcut method to get just the fourier fingerprint. This includes 1. Calculating the coefficients (using n_samples) 2. Correlating the result from 1) using method 3. Weighting the correlation matrix (if weight is True) 4. Remove redundancies (if trim_redundant is True)

Parameters:

Name Type Description Default
model Model

The QFM model

required
n_samples int

Number of samples to calculate average of coefficients

required
random_key Optional[PRNGKey]

JAX random key for parameter initialization. If None, uses the model's internal random key.

None
method Optional[str]

Correlation method. Supported values are "pearson", "complex_pearson", "spearman", and "covariance". Defaults to "pearson".

'pearson'
scale Optional[bool]

Whether to scale the number of samples. Defaults to False.

False
weight Optional[bool]

Whether to weight the correlation matrix. Defaults to False.

False
trim_redundant Optional[bool]

Whether to remove redundant correlations. Defaults to True.

True
nan_to_one Optional[bool]

Whether to set nan to 1. Defaults to False.

False
**kwargs Any

Additional keyword arguments for the model function.

{}

Returns:

Type Description
ndarray

Tuple[jnp.ndarray, jnp.ndarray]: The fourier fingerprint and the

ndarray

corresponding frequency indices. If trim_redundant is True the

Tuple[ndarray, ndarray]

frequencies are returned as a (row_freqs, col_freqs) tuple that

Tuple[ndarray, ndarray]

labels the two (redundancy-trimmed) matrix axes; otherwise the

Tuple[ndarray, ndarray]

full frequency vector is returned.

Source code in qml_essentials/coefficients.py
@classmethod
def get_fourier_fingerprint(
    cls,
    model: Model,
    n_samples: int,
    random_key: Optional[random.PRNGKey] = None,
    method: Optional[str] = "pearson",
    scale: Optional[bool] = False,
    weight: Optional[bool] = False,
    trim_redundant: Optional[bool] = True,
    nan_to_one: Optional[bool] = False,
    **kwargs: Any,
) -> Tuple[jnp.ndarray, jnp.ndarray]:
    """
    Shortcut method to get just the fourier fingerprint.
    This includes
    1. Calculating the coefficients (using `n_samples`)
    2. Correlating the result from 1) using `method`
    3. Weighting the correlation matrix (if `weight` is True)
    4. Remove redundancies (if `trim_redundant` is True)

    Args:
        model (Model): The QFM model
        n_samples (int): Number of samples to calculate average of coefficients
        random_key (Optional[random.PRNGKey]): JAX random key for parameter
            initialization. If None, uses the model's internal random key.
        method (Optional[str], optional): Correlation method. Supported values are
            "pearson", "complex_pearson", "spearman", and "covariance".
            Defaults to "pearson".
        scale (Optional[bool], optional): Whether to scale the number of samples.
            Defaults to False.
        weight (Optional[bool], optional): Whether to weight the correlation matrix.
            Defaults to False.
        trim_redundant (Optional[bool], optional): Whether to remove redundant
            correlations. Defaults to True.
        nan_to_one (Optional[bool], optional): Whether to set nan to 1.
            Defaults to False.
        **kwargs: Additional keyword arguments for the model function.

    Returns:
        Tuple[jnp.ndarray, jnp.ndarray]: The fourier fingerprint and the
        corresponding frequency indices. If `trim_redundant` is True the
        frequencies are returned as a `(row_freqs, col_freqs)` tuple that
        labels the two (redundancy-trimmed) matrix axes; otherwise the
        full frequency vector is returned.
    """
    _, coeffs, freqs = cls._calculate_coefficients(
        model, n_samples, random_key, scale, **kwargs
    )

    # Memory-efficient fast path
    if trim_redundant and not weight:
        pos_idx = cls._calculate_mask(freqs)
        pos_freqs = cls._flat_frequencies(freqs)[pos_idx]

        # Flatten all frequency axes; the last axis is the sample
        # axis. `_calculate_mask` returns flat indices in C order,
        # matching this reshape.
        coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1])
        coeffs_sub = coeffs_flat[pos_idx]

        fourier_fingerprint = cls._correlate(coeffs_sub.transpose(), method=method)

        if nan_to_one:
            fourier_fingerprint = jnp.where(
                jnp.isnan(fourier_fingerprint), 1.0, fourier_fingerprint
            )

        M = fourier_fingerprint.shape[0]
        lower_tri_mask = jnp.tri(M, k=-1, dtype=bool)
        fourier_fingerprint = jnp.where(
            lower_tri_mask, fourier_fingerprint, jnp.nan
        )

        row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1)
        col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0)
        fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask]

        return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask])

    fourier_fingerprint = cls._correlate(coeffs.transpose(), method=method)

    if nan_to_one:
        # set nan to 1
        fourier_fingerprint[jnp.isnan(fourier_fingerprint)] = 1.0

    # perform weighting if requested
    fourier_fingerprint = (
        cls._weighting_mean(fourier_fingerprint, coeffs)
        if weight
        else fourier_fingerprint
    )

    if trim_redundant:
        pos_idx = cls._calculate_mask(freqs)
        pos_freqs = cls._flat_frequencies(freqs)[pos_idx]

        # restrict to the positive-frequency sub-block (M x M with
        # M = number of non-negative flat-frequencies) instead of
        # building a full N x N mask. This avoids the O(N^2) float
        fourier_fingerprint = fourier_fingerprint[pos_idx][:, pos_idx]

        # keep only the strict lower triangle; the rest -> nan
        M = fourier_fingerprint.shape[0]
        lower_tri_mask = jnp.tri(M, k=-1, dtype=bool)
        fourier_fingerprint = jnp.where(
            lower_tri_mask, fourier_fingerprint, jnp.nan
        )

        row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1)
        col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0)

        fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask]

        return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask])

    return fourier_fingerprint, freqs

Datasets#

from qml_essentials.coefficients import Datasets
Source code in qml_essentials/coefficients.py
class Datasets:
    @classmethod
    def generate_fourier_series(
        cls,
        random_key: random.PRNGKey,
        model: Model,
        coefficients_min: float = 0.0,
        coefficients_max: float = 1.0,
        zero_centered: bool = False,
    ) -> jnp.ndarray:
        """
        Generates the Fourier series representation of a function.
        It uses the `model.frequencies` property to retrieve the frequency
        information. This ensures that the resulting Fourier series is
        compatible with the model.

        This function is capable of generating $D$-dimensional Fourier series
        (again defined by `model.n_input_feat`).
        The highest frequency $N$ is retrieved per dimension.

        Samples of the Fourier coefficients are drawn from a uniform circle.

        Args:
            random_key (random.PRNGKey): Random number key for JAX.
            model (Model): The quantum circuit model.
            coefficients_min (float, optional): Minimum value for the coefficients.
                Defaults to 0.0.
            coefficients_max (float, optional): Maximum value for the coefficients.
                Defaults to 1.0.
            zero_centered (bool, optional): Whether to zero-center the coefficients.
                Defaults to False.

        Returns:
            jnp.ndarray: Input domain samples with shape ((N,)*D, D)
            jnp.ndarray: Fourier series values with shape ((N,)*D)
            jnp.ndarray: Fourier coefficients with shape ((N,)*D)

        """
        # TODO: the following code can be considered to
        # capturing a truly random spectrum.
        # add some constraints on the spectrum, i.e. not fully

        # Note: one key observation for understanding the following code is,
        # that instead of wrapping your head around symmetries in multi-
        # dimensional coefficient matrices, one can simply look at the flattened
        # version of such a matrix and reshape later. It just works out.

        # going from [0, 2pi] with the resolution required for highest frequency
        # permute with input dimensionality to get an n-d grid of domain samples
        # the output shape comes from the fact that want to create a "coordinate system"
        domain_samples_per_input_dim = jnp.stack(
            jnp.meshgrid(
                *[jnp.arange(0, 2 * jnp.pi, 2 * jnp.pi / d) for d in model.degree]
            )
        ).T.reshape(-1, model.n_input_feat)

        # generate the frequency indices for each dimension.
        # this will have the same shape as the domain samples
        frequencies = jnp.stack(jnp.meshgrid(*model.frequencies)).T.reshape(
            -1, model.n_input_feat
        )

        # using the frequency information, sample coefficients for each dimension
        # shape: (input_dims, n_freqs_per_input_dim // 2 + 1)

        coefficients = cls.uniform_circle(
            random_key,
            low=coefficients_min,
            high=coefficients_max,
            size=math.prod(model.degree) // 2 + 1,
        )

        # zero center (first coeff = 0)
        # we can assume the first coeff is the offset, because we're dealing
        # with a non-symmetric spectrum here
        if zero_centered:
            coefficients = coefficients.at[0].set(0.0)
        else:
            coefficients = coefficients.at[0].set(coefficients[0].real)

        # ensure symmetry (here, non_negative_ is removed!),
        # giving us the full coefficients vector
        coefficients = jnp.concat(
            [
                jnp.flip(coefficients[..., 1:]).conjugate(),
                coefficients,
            ],
            axis=-1,
        )

        # Vectorized version of $f(x) = \sum_{n=0}^{N-1} c_n * e^{i * \omega_n * x}$
        # it takes into account the input dimension, i.e. the output is a matrix
        # normalization uses the n_freqs component of the coefficients
        values = jnp.real(
            (
                jnp.exp(1j * (domain_samples_per_input_dim @ frequencies.T))
                * coefficients
            ).sum(axis=1)
            / coefficients.size
        )

        # return all the information we have
        return [
            domain_samples_per_input_dim.reshape(*model.degree, -1),
            values.reshape(model.degree),
            coefficients.reshape(model.degree),
        ]

    @classmethod
    def uniform_circle(
        cls,
        random_key: random.PRNGKey,
        size: Union[jnp.ndarray, List, int],
        low=0.0,
        high=1.0,
    ):
        """
        Random number generator for complex numbers sampled inside the unit circle

        Args:
            random_key (random.PRNGKey): Random number key for JAX.
            size (Union[jnp.ndarray, int]): Number of samples. If a 2D array is passed,
                the first dimension will be the number of dimensions.
            low (float, optional): Minimum Radius. Defaults to 0.0.
            high (float, optional): Maximum Radius. Defaults to 1.0.

        Returns
            jnp.ndarray: Array of complex numbers with shape of `size`
        """

        if isinstance(size, int):
            size = jnp.array([size])

        random_key, random_key1 = random.split(random_key)
        return jnp.sqrt(
            random.uniform(random_key, size, minval=low, maxval=high)
        ) * jnp.exp(2j * jnp.pi * random.uniform(random_key1, size))

generate_fourier_series(random_key, model, coefficients_min=0.0, coefficients_max=1.0, zero_centered=False) classmethod #

Generates the Fourier series representation of a function. It uses the model.frequencies property to retrieve the frequency information. This ensures that the resulting Fourier series is compatible with the model.

This function is capable of generating \(D\)-dimensional Fourier series (again defined by model.n_input_feat). The highest frequency \(N\) is retrieved per dimension.

Samples of the Fourier coefficients are drawn from a uniform circle.

Parameters:

Name Type Description Default
random_key PRNGKey

Random number key for JAX.

required
model Model

The quantum circuit model.

required
coefficients_min float

Minimum value for the coefficients. Defaults to 0.0.

0.0
coefficients_max float

Maximum value for the coefficients. Defaults to 1.0.

1.0
zero_centered bool

Whether to zero-center the coefficients. Defaults to False.

False

Returns:

Type Description
ndarray

jnp.ndarray: Input domain samples with shape ((N,)*D, D)

ndarray

jnp.ndarray: Fourier series values with shape ((N,)*D)

ndarray

jnp.ndarray: Fourier coefficients with shape ((N,)*D)

Source code in qml_essentials/coefficients.py
@classmethod
def generate_fourier_series(
    cls,
    random_key: random.PRNGKey,
    model: Model,
    coefficients_min: float = 0.0,
    coefficients_max: float = 1.0,
    zero_centered: bool = False,
) -> jnp.ndarray:
    """
    Generates the Fourier series representation of a function.
    It uses the `model.frequencies` property to retrieve the frequency
    information. This ensures that the resulting Fourier series is
    compatible with the model.

    This function is capable of generating $D$-dimensional Fourier series
    (again defined by `model.n_input_feat`).
    The highest frequency $N$ is retrieved per dimension.

    Samples of the Fourier coefficients are drawn from a uniform circle.

    Args:
        random_key (random.PRNGKey): Random number key for JAX.
        model (Model): The quantum circuit model.
        coefficients_min (float, optional): Minimum value for the coefficients.
            Defaults to 0.0.
        coefficients_max (float, optional): Maximum value for the coefficients.
            Defaults to 1.0.
        zero_centered (bool, optional): Whether to zero-center the coefficients.
            Defaults to False.

    Returns:
        jnp.ndarray: Input domain samples with shape ((N,)*D, D)
        jnp.ndarray: Fourier series values with shape ((N,)*D)
        jnp.ndarray: Fourier coefficients with shape ((N,)*D)

    """
    # TODO: the following code can be considered to
    # capturing a truly random spectrum.
    # add some constraints on the spectrum, i.e. not fully

    # Note: one key observation for understanding the following code is,
    # that instead of wrapping your head around symmetries in multi-
    # dimensional coefficient matrices, one can simply look at the flattened
    # version of such a matrix and reshape later. It just works out.

    # going from [0, 2pi] with the resolution required for highest frequency
    # permute with input dimensionality to get an n-d grid of domain samples
    # the output shape comes from the fact that want to create a "coordinate system"
    domain_samples_per_input_dim = jnp.stack(
        jnp.meshgrid(
            *[jnp.arange(0, 2 * jnp.pi, 2 * jnp.pi / d) for d in model.degree]
        )
    ).T.reshape(-1, model.n_input_feat)

    # generate the frequency indices for each dimension.
    # this will have the same shape as the domain samples
    frequencies = jnp.stack(jnp.meshgrid(*model.frequencies)).T.reshape(
        -1, model.n_input_feat
    )

    # using the frequency information, sample coefficients for each dimension
    # shape: (input_dims, n_freqs_per_input_dim // 2 + 1)

    coefficients = cls.uniform_circle(
        random_key,
        low=coefficients_min,
        high=coefficients_max,
        size=math.prod(model.degree) // 2 + 1,
    )

    # zero center (first coeff = 0)
    # we can assume the first coeff is the offset, because we're dealing
    # with a non-symmetric spectrum here
    if zero_centered:
        coefficients = coefficients.at[0].set(0.0)
    else:
        coefficients = coefficients.at[0].set(coefficients[0].real)

    # ensure symmetry (here, non_negative_ is removed!),
    # giving us the full coefficients vector
    coefficients = jnp.concat(
        [
            jnp.flip(coefficients[..., 1:]).conjugate(),
            coefficients,
        ],
        axis=-1,
    )

    # Vectorized version of $f(x) = \sum_{n=0}^{N-1} c_n * e^{i * \omega_n * x}$
    # it takes into account the input dimension, i.e. the output is a matrix
    # normalization uses the n_freqs component of the coefficients
    values = jnp.real(
        (
            jnp.exp(1j * (domain_samples_per_input_dim @ frequencies.T))
            * coefficients
        ).sum(axis=1)
        / coefficients.size
    )

    # return all the information we have
    return [
        domain_samples_per_input_dim.reshape(*model.degree, -1),
        values.reshape(model.degree),
        coefficients.reshape(model.degree),
    ]

uniform_circle(random_key, size, low=0.0, high=1.0) classmethod #

Random number generator for complex numbers sampled inside the unit circle

Parameters:

Name Type Description Default
random_key PRNGKey

Random number key for JAX.

required
size Union[ndarray, int]

Number of samples. If a 2D array is passed, the first dimension will be the number of dimensions.

required
low float

Minimum Radius. Defaults to 0.0.

0.0
high float

Maximum Radius. Defaults to 1.0.

1.0

Returns jnp.ndarray: Array of complex numbers with shape of size

Source code in qml_essentials/coefficients.py
@classmethod
def uniform_circle(
    cls,
    random_key: random.PRNGKey,
    size: Union[jnp.ndarray, List, int],
    low=0.0,
    high=1.0,
):
    """
    Random number generator for complex numbers sampled inside the unit circle

    Args:
        random_key (random.PRNGKey): Random number key for JAX.
        size (Union[jnp.ndarray, int]): Number of samples. If a 2D array is passed,
            the first dimension will be the number of dimensions.
        low (float, optional): Minimum Radius. Defaults to 0.0.
        high (float, optional): Maximum Radius. Defaults to 1.0.

    Returns
        jnp.ndarray: Array of complex numbers with shape of `size`
    """

    if isinstance(size, int):
        size = jnp.array([size])

    random_key, random_key1 = random.split(random_key)
    return jnp.sqrt(
        random.uniform(random_key, size, minval=low, maxval=high)
    ) * jnp.exp(2j * jnp.pi * random.uniform(random_key1, size))

Topologies#

from qml_essentials.topologies import Topology

Generates [control, target] wire-pair lists for two-qubit gates.

All public methods are static and share a small set of private helpers so that related topologies (e.g. linear / circular, brick_layer / brick_layer_wrap) re-use the same core logic.

Raises#

ValueError If n_qubits < 2 is passed to any topology method.

Source code in qml_essentials/topologies.py
class Topology:
    """
    Generates [control, target] wire-pair lists for two-qubit gates.

    All public methods are static and share a small set of private
    helpers so that related topologies (e.g. ``linear`` / ``circular``,
    ``brick_layer`` / ``brick_layer_wrap``) re-use the same core logic.

    Raises
    ------
    ValueError
        If ``n_qubits < 2`` is passed to any topology method.
    """

    @classmethod
    def stairs(
        cls,
        n_qubits: int,
        offset: Union[int, Callable] = 0,
        wrap=False,
        reverse: bool = True,
        mirror: bool = True,
        span: Union[int, Callable] = 1,
        stride: int = 1,
        modulo: bool = True,
    ) -> List[List[int]]:
        """
        Unified generator for nearest-neighbour and spand pair topologies.
        Produces ``[control, target]`` pairs of qubits.

        The default values, produce an "upstairs" entangling sequence
        without wrapping around the last gate.

        Parameters
        ----------
        n_qubits : int
            Number of qubits.
        offset : Union[int, Callable]
            Offset for starting the entangling sequence.
            Can either be a integer or a callable that takes n_qubits as input.
        wrap : bool
            Wraps around the entangling gates.
        reverse : bool
            Reverses both the iteration direction (upstairs/ downstairs)
        mirror: bool
            Flip target/ control qubit
        span : int
            Offset between control and target qubit. Defaults to 1
        stride : int
            Step size for entangling gates. Defaults to 1, meaning a stair
            pattern will be generated.
        modulo : bool
            If a gate should be placed when the iterator decreases below 0
            or exceeds n_qubits. Defaults to True

        Returns
        -------
        List[List[int]]
        """
        ctrls = []
        targets = []

        n_gates = n_qubits if wrap else n_qubits - 1
        _offset = offset(n_qubits) if callable(offset) else offset
        _span = span(n_qubits) if callable(span) else span

        for q in range(0, n_gates, stride):
            _target = q + _offset + _span
            if _target >= n_qubits and not modulo:
                continue
            _control = q + _offset
            if _control < 0 and not modulo:
                continue

            _target = _target % n_qubits
            _control = _control % n_qubits

            if _target == _control:
                log.warning("Skipping gate where control == target")
                continue

            targets += [_target]
            ctrls += [_control]

        if reverse:
            ctrls = reversed(ctrls)
            targets = reversed(targets)

        if mirror:
            ctrls, targets = targets, ctrls

        pairs = list(zip(ctrls, targets, strict=True))

        return pairs

    @classmethod
    def bricks(cls, n_qubits: int, **kwargs) -> List[List[int]]:
        kwargs.setdefault("stride", 2)
        kwargs.setdefault("modulo", False)
        return cls.stairs(n_qubits=n_qubits, **kwargs)

    @classmethod
    def all_to_all(cls, n_qubits: int) -> List[List[int]]:
        """Every ordered pair ``(i, j)`` with ``i ≠ j``."""
        pairs: List[List[int]] = []
        for ql in range(n_qubits):
            for q in range(n_qubits):
                if q != ql:
                    pairs.append(
                        [
                            n_qubits - ql - 1,
                            (n_qubits - q - 1) % n_qubits,
                        ]
                    )
        return pairs

all_to_all(n_qubits) classmethod #

Every ordered pair (i, j) with i ≠ j.

Source code in qml_essentials/topologies.py
@classmethod
def all_to_all(cls, n_qubits: int) -> List[List[int]]:
    """Every ordered pair ``(i, j)`` with ``i ≠ j``."""
    pairs: List[List[int]] = []
    for ql in range(n_qubits):
        for q in range(n_qubits):
            if q != ql:
                pairs.append(
                    [
                        n_qubits - ql - 1,
                        (n_qubits - q - 1) % n_qubits,
                    ]
                )
    return pairs

stairs(n_qubits, offset=0, wrap=False, reverse=True, mirror=True, span=1, stride=1, modulo=True) classmethod #

Unified generator for nearest-neighbour and spand pair topologies. Produces [control, target] pairs of qubits.

The default values, produce an "upstairs" entangling sequence without wrapping around the last gate.

Parameters#

n_qubits : int Number of qubits. offset : Union[int, Callable] Offset for starting the entangling sequence. Can either be a integer or a callable that takes n_qubits as input. wrap : bool Wraps around the entangling gates. reverse : bool Reverses both the iteration direction (upstairs/ downstairs) mirror: bool Flip target/ control qubit span : int Offset between control and target qubit. Defaults to 1 stride : int Step size for entangling gates. Defaults to 1, meaning a stair pattern will be generated. modulo : bool If a gate should be placed when the iterator decreases below 0 or exceeds n_qubits. Defaults to True

Returns#

List[List[int]]

Source code in qml_essentials/topologies.py
@classmethod
def stairs(
    cls,
    n_qubits: int,
    offset: Union[int, Callable] = 0,
    wrap=False,
    reverse: bool = True,
    mirror: bool = True,
    span: Union[int, Callable] = 1,
    stride: int = 1,
    modulo: bool = True,
) -> List[List[int]]:
    """
    Unified generator for nearest-neighbour and spand pair topologies.
    Produces ``[control, target]`` pairs of qubits.

    The default values, produce an "upstairs" entangling sequence
    without wrapping around the last gate.

    Parameters
    ----------
    n_qubits : int
        Number of qubits.
    offset : Union[int, Callable]
        Offset for starting the entangling sequence.
        Can either be a integer or a callable that takes n_qubits as input.
    wrap : bool
        Wraps around the entangling gates.
    reverse : bool
        Reverses both the iteration direction (upstairs/ downstairs)
    mirror: bool
        Flip target/ control qubit
    span : int
        Offset between control and target qubit. Defaults to 1
    stride : int
        Step size for entangling gates. Defaults to 1, meaning a stair
        pattern will be generated.
    modulo : bool
        If a gate should be placed when the iterator decreases below 0
        or exceeds n_qubits. Defaults to True

    Returns
    -------
    List[List[int]]
    """
    ctrls = []
    targets = []

    n_gates = n_qubits if wrap else n_qubits - 1
    _offset = offset(n_qubits) if callable(offset) else offset
    _span = span(n_qubits) if callable(span) else span

    for q in range(0, n_gates, stride):
        _target = q + _offset + _span
        if _target >= n_qubits and not modulo:
            continue
        _control = q + _offset
        if _control < 0 and not modulo:
            continue

        _target = _target % n_qubits
        _control = _control % n_qubits

        if _target == _control:
            log.warning("Skipping gate where control == target")
            continue

        targets += [_target]
        ctrls += [_control]

    if reverse:
        ctrls = reversed(ctrls)
        targets = reversed(targets)

    if mirror:
        ctrls, targets = targets, ctrls

    pairs = list(zip(ctrls, targets, strict=True))

    return pairs

Operations#

from qml_essentials.operations import Operation

Base class for any quantum operation or observable.

Further gates should inherit from this class to realise more specific operations. Generally, operations are created by instantiation inside a circuit function passed to :class:Script; the instance is automatically appended to the active tape.

An Operation can also serve as an observable: its matrix is used to compute expectation values via apply_to_state / apply_to_density.

Attributes:

Name Type Description
_matrix ndarray

Class-level default gate matrix. Subclasses set this to their fixed unitary. Instances may override it via the matrix argument to __init__.

_num_wires Optional[int]

Expected number of wires for this gate. Subclasses set this to enforce wire count validation. None means any number of wires is accepted.

_param_names Tuple[str, ...]

Tuple of attribute names for the gate parameters. Used by :attr:parameters and :meth:__repr__.

Source code in qml_essentials/operations.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
class Operation:
    """Base class for any quantum operation or observable.

    Further gates should inherit from this class to realise more specific
    operations.  Generally, operations are created by instantiation inside a
    circuit function passed to :class:`Script`; the instance is
    automatically appended to the active tape.

    An ``Operation`` can also serve as an *observable*: its matrix is used to
    compute expectation values via ``apply_to_state`` / ``apply_to_density``.

    Attributes:
        _matrix: Class-level default gate matrix.  Subclasses set this to their
            fixed unitary.  Instances may override it via the *matrix* argument
            to ``__init__``.
        _num_wires: Expected number of wires for this gate.  Subclasses set
            this to enforce wire count validation.  ``None`` means any number
            of wires is accepted.
        _param_names: Tuple of attribute names for the gate parameters.
            Used by :attr:`parameters` and :meth:`__repr__`.
    """

    # Subclasses should set this to the gate's unitary / matrix
    # Whether this is a controlled operation
    is_controlled = False
    # Whether this gate is a Clifford gate (normalises the Pauli group
    is_clifford = False

    _matrix: jnp.ndarray = None
    _num_wires: Optional[int] = None
    _param_names: Tuple[str, ...] = ()

    def __init__(
        self,
        wires: Union[int, List[int]] = 0,
        matrix: Optional[jnp.ndarray] = None,
        record: bool = True,
        name: Optional[str] = None,
    ) -> None:
        """Initialise the operation and optionally register it on the active tape.

        Args:
            wires: Qubit index or list of qubit indices this operation acts on.
            matrix: Optional explicit gate matrix.  When provided it overrides
                the class-level ``_matrix`` attribute.
            record: If ``True`` (default) and a tape is currently recording,
                append this operation to the tape.  Set to ``False`` for
                auxiliary objects that should not appear in the circuit
                (e.g. Hamiltonians used only to build time-dependent
                evolutions).
            name: Optional explicit name for this operation.  When ``None``
                (default), the class name is used (e.g. ``"RX"``).

        Raises:
            ValueError: If ``_num_wires`` is set and the number of wires
                doesn't match, or if duplicate wires are provided.
        """
        self.name = name or self.__class__.__name__
        self.wires = list(wires) if isinstance(wires, (list, tuple)) else [wires]

        if self._num_wires is not None and len(self.wires) != self._num_wires:
            raise ValueError(
                f"{self.name} expects {self._num_wires} wire(s), "
                f"got {len(self.wires)}: {self.wires}"
            )
        if len(self.wires) != len(set(self.wires)):
            raise ValueError(f"{self.name} received duplicate wires: {self.wires}")

        if matrix is not None:
            self._matrix = matrix

        # If a tape is currently recording, append ourselves
        if record:
            tape = active_tape()
            if tape is not None:
                tape.append(self)

    @property
    def parameters(self) -> list:
        """Return the list of numeric parameters for this operation.

        Uses the declarative ``_param_names`` tuple to collect parameter
        values in a canonical order.  Non-parametrized gates return an
        empty list.

        Returns:
            List of parameter values (floats or JAX arrays).
        """
        return [getattr(self, name) for name in self._param_names]

    def __repr__(self) -> str:
        """Return a human-readable representation of this operation.

        Returns:
            A string like ``"RX(0.5000, wires=[0])"`` or ``"CX(wires=[0, 1])"``.
        """
        params = self.parameters
        if params:
            param_str = ", ".join(
                (
                    f"{float(v):.4f}"
                    if isinstance(v, (float, np.floating, jnp.ndarray))
                    else str(v)
                )
                for v in params
            )
            return f"{self.name}({param_str}, wires={self.wires})"
        return f"{self.name}(wires={self.wires})"

    @property
    def matrix(self) -> jnp.ndarray:
        """Return the base matrix of this operation (before lifting).

        Returns:
            The gate matrix as a JAX array.

        Raises:
            NotImplementedError: If the subclass has not defined ``_matrix``.
        """
        if self._matrix is None:
            raise NotImplementedError(
                f"{self.__class__.__name__} does not define a matrix."
            )
        return self._matrix

    def decompose(self) -> List["Operation"]:
        """Decompose this operation into a list of more primitive operations.

        The returned operations are created with ``record=False`` so the caller
        controls where they are placed.  Reused e.g. by
        :meth:`~qml_essentials.pauli.PauliCircuit.get_clifford_pauli_gates` to
        express composite gates in terms of Clifford + Pauli-rotation primitives.

        Returns:
            List of :class:`Operation` instances equivalent to this gate.

        Raises:
            NotImplementedError: If the gate has no decomposition (it is itself
                primitive).
        """
        raise NotImplementedError(
            f"{self.__class__.__name__} does not define a decomposition."
        )

    @property
    def wires(self) -> List[int]:
        """Qubit indices this operation acts on.

        Returns:
            List of integer qubit indices.
        """
        return self._wires

    @wires.setter
    def wires(self, wires: Union[int, List[int]]) -> None:
        """Set the qubit indices for this operation.

        Args:
            wires: A single qubit index or a list of qubit indices.
        """
        if isinstance(wires, (list, tuple)):
            self._wires = list(wires)
        else:
            self._wires = [wires]

    def _update_tape_operation(self, op: "Operation") -> None:
        """
        If ``self`` is already on the active tape (the typical case when
        chaining ``Gate(...).dagger()``), it is replaced by the daggered
        operation so that only U\\dagger appears on the tape —
        not both U and ``U\\dagger``.
        Note that this should only be called immediately after the tape is updated.s

        Args:
            op (Operation): New replaced operation on the tape
        """
        # If self was recorded on the tape, replace it with the daggered op.
        tape = active_tape()
        if tape is not None:
            if tape and tape[-1] is self:
                tape[-1] = op
            else:
                tape.append(op)

    def dagger(self) -> "Operation":
        """Return a new operation, the conjugate transpose (``U\\dagger``)
        Usage inside a circuit function::

            RX(0.5, wires=0).dagger()

        Returns:
            A new :class:`Operation` with matrix ``U\\dagger`` acting on the same wires.
        """
        mat = jnp.conj(self._matrix).T
        op = Operation(wires=self.wires, matrix=mat, record=False)

        self._update_tape_operation(op)

        return op

    def power(self, power) -> "Operation":
        """Return a new operation, the power (``U^power``)
        Usage inside a circuit function::

            PauliX(wires=0).power(2)

        Returns:
            A new :class:`Operation` with matrix ``U\\dagger`` acting on the same wires.
        """
        # TODO: support fractional powers
        mat = jnp.linalg.matrix_power(self._matrix, power)
        op = Operation(wires=self.wires, matrix=mat, record=False)

        self._update_tape_operation(op)

        return op

    def __mul__(self, other: Union[float, "Operation"]) -> "Operation":
        """Return a new operation, the product between U and a scalar (``U*x``)
        or the composition of two operations.
        Usage inside a circuit function::

            PauliX(wires=0) * x
            PauliX(wires=0) * PauliZ(wires=0)

        Returns:
            A new :class:`Operation` with matrix ``U*x`` acting on the same wires,
            or the composed matrix acting on the appropriate wires.
        """
        if isinstance(other, Operation):
            return self.__matmul__(other)

        mat = other * self._matrix
        op = Operation(wires=self.wires, matrix=mat, record=False)

        self._update_tape_operation(op)

        return op

    # Also overwrite * for right operands
    __rmul__ = __mul__

    def __add__(self, other: "Operation") -> "Operation":
        """Element-wise addition of two operations on the same wires.

        Returns:
            A new :class:`Operation` whose matrix is the sum of both matrices.

        Raises:
            ValueError: If the wire sets differ.
        """
        if sorted(self.wires) != sorted(other.wires):
            raise ValueError(
                f"Can only add operations acting on the same set of wires, "
                f"got {self.wires} and {other.wires}"
            )

        op = Operation(
            wires=self.wires,
            matrix=self.matrix + other.matrix,
            record=False,
        )
        return op

    def prod(self, *ops: "Operation") -> "Operation":
        """Construct the generalized product (tensor or matrix)
        of this operation with others.

        The resulting operation acts on the union of all wire sets.
        If the wire sets are disjoint, this is a Kronecker product.
        If the wire sets overlap, the corresponding matrices are multiplied.

        Usage::

            res = op1.prod(op2, op3)
            # or
            res = Operation.prod(op1, op2, op3)

        Args:
            *ops: Variable number of :class:`Operation` instances.

        Returns:
            A new :class:`Operation` representing the composed operation.
        """
        if not ops:
            return self

        all_ops = (self,) + ops
        all_wires = []
        for op in all_ops:
            for w in op.wires:
                if w not in all_wires:
                    all_wires.append(w)

        n = len(all_wires)

        mat = _embed_matrix(all_ops[0].matrix, all_ops[0].wires, all_wires, n)
        for op in all_ops[1:]:
            mat_other = _embed_matrix(op.matrix, op.wires, all_wires, n)
            mat = mat @ mat_other

        op_names = "*".join(op.name for op in all_ops)
        return Operation(
            wires=all_wires, matrix=mat, name=f"Prod({op_names})", record=False
        )

    def __matmul__(self, other: "Operation") -> "Operation":
        """Tensor (Kronecker) product or matrix product of two operations.

        The resulting operation acts on the union of both wire sets.
        If the wire sets are disjoint, this is a Kronecker product.
        If the wire sets overlap, the corresponding matrices are multiplied.

        Returns:
            A new :class:`Operation` whose matrix represents the composed
            operation on the unified wire set.
        """
        if not isinstance(other, Operation):
            return NotImplemented

        return self.prod(other)

    def lifted_matrix(self, n_qubits: int) -> jnp.ndarray:
        """Return the full ``2**n x 2**n`` matrix embedding this gate.

        Embeds the ``k``-qubit gate matrix into the ``n``-qubit Hilbert space
        by applying it to the identity matrix via :meth:`apply_to_state`.
        This is useful for computing ``Tr(O·\\rho )`` directly without vmap.

        Args:
            n_qubits: Total number of qubits in the circuit.

        Returns:
            The ``(2**n, 2**n)`` matrix of this operation in the full space.
        """
        dim = 2**n_qubits
        # Apply the gate to each basis vector (column of identity)
        return jax.vmap(lambda col: self.apply_to_state(col, n_qubits))(
            jnp.eye(dim, dtype=_cdtype())
        ).T

    def apply_to_state(self, state: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Apply this gate to a statevector via tensor contraction.

        The statevector (shape ``(2**n,)``) is reshaped into a rank-n tensor
        of shape ``(2,)*n``.  The gate (shape ``(2**k, 2**k)``) is reshaped to
        ``(2,)*2k`` and contracted against the k target wire axes.

        Memory footprint is O(2**n) and the operation supports arbitrary k.
        The implementation is fully differentiable through JAX.

        Args:
            state: Statevector of shape ``(2**n_qubits,)``.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            Updated statevector of shape ``(2**n_qubits,)``.
        """
        k = len(self.wires)
        gate_tensor = self.matrix.reshape((2,) * 2 * k)
        psi = state.reshape((2,) * n_qubits)
        psi_out = _contract_and_restore(psi, gate_tensor, k, self.wires)
        return psi_out.reshape(2**n_qubits)

    def apply_to_state_tensor(self, psi: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Apply this gate to a statevector already in tensor form.

        Like :meth:`apply_to_state` but expects the state in rank-n tensor
        form ``(2,)*n`` and returns the result in the same form.  This avoids
        the ``reshape`` calls at the per-gate level when the simulation loop
        keeps the state in tensor form throughout.

        Args:
            psi: Statevector tensor of shape ``(2,)*n_qubits``.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            Updated statevector tensor of shape ``(2,)*n_qubits``.
        """
        k = len(self.wires)
        gate_tensor = self._gate_tensor(k)
        return _contract_and_restore(psi, gate_tensor, k, self.wires)

    def _gate_tensor(self, k: int) -> jnp.ndarray:
        """Return the gate matrix reshaped to ``(2,)*2k`` tensor form.

        The result is cached on the instance so repeated calls (e.g. from
        density-matrix simulation which applies U and U*) avoid redundant
        reshape dispatch.

        Args:
            k: Number of qubits the gate acts on.

        Returns:
            Gate matrix as a rank-2k tensor of shape ``(2,)*2k``.
        """
        cached = getattr(self, "_cached_gate_tensor", None)
        if cached is not None:
            return cached
        gt = self.matrix.reshape((2,) * 2 * k)
        # Only cache for non-parametrized gates (whose matrix is a class attr)
        if self._matrix is self.__class__._matrix:
            object.__setattr__(self, "_cached_gate_tensor", gt)
        return gt

    def apply_to_density(self, rho: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Apply this gate to a density matrix via \\rho -> U\\rho U\\dagger.

        The density matrix (shape ``(2**n, 2**n)``) is treated as a rank-*2n*
        tensor with n "ket" axes (0..n-1) and n "bra" axes (n..2n-1).
        U acts on the ket half; U* acts on the bra half.  Both contractions
        use the shared :func:`_contract_and_restore` helper, keeping the
        operation allocation-free with respect to building full unitaries.

        Args:
            rho: Density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            Updated density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
        """
        k = len(self.wires)
        U = self._gate_tensor(k)
        U_conj = jnp.conj(U)

        rho_t = rho.reshape((2,) * 2 * n_qubits)

        # Apply U to ket axes, U\\dagger to bra axes
        rho_t = _contract_and_restore(rho_t, U, k, self.wires)
        bra_wires = [w + n_qubits for w in self.wires]
        rho_t = _contract_and_restore(rho_t, U_conj, k, bra_wires)

        return rho_t.reshape(2**n_qubits, 2**n_qubits)

matrix property #

Return the base matrix of this operation (before lifting).

Returns:

Type Description
ndarray

The gate matrix as a JAX array.

Raises:

Type Description
NotImplementedError

If the subclass has not defined _matrix.

parameters property #

Return the list of numeric parameters for this operation.

Uses the declarative _param_names tuple to collect parameter values in a canonical order. Non-parametrized gates return an empty list.

Returns:

Type Description
list

List of parameter values (floats or JAX arrays).

wires property writable #

Qubit indices this operation acts on.

Returns:

Type Description
List[int]

List of integer qubit indices.

__add__(other) #

Element-wise addition of two operations on the same wires.

Returns:

Type Description
Operation

A new :class:Operation whose matrix is the sum of both matrices.

Raises:

Type Description
ValueError

If the wire sets differ.

Source code in qml_essentials/operations.py
def __add__(self, other: "Operation") -> "Operation":
    """Element-wise addition of two operations on the same wires.

    Returns:
        A new :class:`Operation` whose matrix is the sum of both matrices.

    Raises:
        ValueError: If the wire sets differ.
    """
    if sorted(self.wires) != sorted(other.wires):
        raise ValueError(
            f"Can only add operations acting on the same set of wires, "
            f"got {self.wires} and {other.wires}"
        )

    op = Operation(
        wires=self.wires,
        matrix=self.matrix + other.matrix,
        record=False,
    )
    return op

__init__(wires=0, matrix=None, record=True, name=None) #

Initialise the operation and optionally register it on the active tape.

Parameters:

Name Type Description Default
wires Union[int, List[int]]

Qubit index or list of qubit indices this operation acts on.

0
matrix Optional[ndarray]

Optional explicit gate matrix. When provided it overrides the class-level _matrix attribute.

None
record bool

If True (default) and a tape is currently recording, append this operation to the tape. Set to False for auxiliary objects that should not appear in the circuit (e.g. Hamiltonians used only to build time-dependent evolutions).

True
name Optional[str]

Optional explicit name for this operation. When None (default), the class name is used (e.g. "RX").

None

Raises:

Type Description
ValueError

If _num_wires is set and the number of wires doesn't match, or if duplicate wires are provided.

Source code in qml_essentials/operations.py
def __init__(
    self,
    wires: Union[int, List[int]] = 0,
    matrix: Optional[jnp.ndarray] = None,
    record: bool = True,
    name: Optional[str] = None,
) -> None:
    """Initialise the operation and optionally register it on the active tape.

    Args:
        wires: Qubit index or list of qubit indices this operation acts on.
        matrix: Optional explicit gate matrix.  When provided it overrides
            the class-level ``_matrix`` attribute.
        record: If ``True`` (default) and a tape is currently recording,
            append this operation to the tape.  Set to ``False`` for
            auxiliary objects that should not appear in the circuit
            (e.g. Hamiltonians used only to build time-dependent
            evolutions).
        name: Optional explicit name for this operation.  When ``None``
            (default), the class name is used (e.g. ``"RX"``).

    Raises:
        ValueError: If ``_num_wires`` is set and the number of wires
            doesn't match, or if duplicate wires are provided.
    """
    self.name = name or self.__class__.__name__
    self.wires = list(wires) if isinstance(wires, (list, tuple)) else [wires]

    if self._num_wires is not None and len(self.wires) != self._num_wires:
        raise ValueError(
            f"{self.name} expects {self._num_wires} wire(s), "
            f"got {len(self.wires)}: {self.wires}"
        )
    if len(self.wires) != len(set(self.wires)):
        raise ValueError(f"{self.name} received duplicate wires: {self.wires}")

    if matrix is not None:
        self._matrix = matrix

    # If a tape is currently recording, append ourselves
    if record:
        tape = active_tape()
        if tape is not None:
            tape.append(self)

__matmul__(other) #

Tensor (Kronecker) product or matrix product of two operations.

The resulting operation acts on the union of both wire sets. If the wire sets are disjoint, this is a Kronecker product. If the wire sets overlap, the corresponding matrices are multiplied.

Returns:

Type Description
Operation

A new :class:Operation whose matrix represents the composed

Operation

operation on the unified wire set.

Source code in qml_essentials/operations.py
def __matmul__(self, other: "Operation") -> "Operation":
    """Tensor (Kronecker) product or matrix product of two operations.

    The resulting operation acts on the union of both wire sets.
    If the wire sets are disjoint, this is a Kronecker product.
    If the wire sets overlap, the corresponding matrices are multiplied.

    Returns:
        A new :class:`Operation` whose matrix represents the composed
        operation on the unified wire set.
    """
    if not isinstance(other, Operation):
        return NotImplemented

    return self.prod(other)

__mul__(other) #

Return a new operation, the product between U and a scalar (U*x) or the composition of two operations. Usage inside a circuit function::

PauliX(wires=0) * x
PauliX(wires=0) * PauliZ(wires=0)

Returns:

Type Description
Operation

A new :class:Operation with matrix U*x acting on the same wires,

Operation

or the composed matrix acting on the appropriate wires.

Source code in qml_essentials/operations.py
def __mul__(self, other: Union[float, "Operation"]) -> "Operation":
    """Return a new operation, the product between U and a scalar (``U*x``)
    or the composition of two operations.
    Usage inside a circuit function::

        PauliX(wires=0) * x
        PauliX(wires=0) * PauliZ(wires=0)

    Returns:
        A new :class:`Operation` with matrix ``U*x`` acting on the same wires,
        or the composed matrix acting on the appropriate wires.
    """
    if isinstance(other, Operation):
        return self.__matmul__(other)

    mat = other * self._matrix
    op = Operation(wires=self.wires, matrix=mat, record=False)

    self._update_tape_operation(op)

    return op

__repr__() #

Return a human-readable representation of this operation.

Returns:

Type Description
str

A string like "RX(0.5000, wires=[0])" or "CX(wires=[0, 1])".

Source code in qml_essentials/operations.py
def __repr__(self) -> str:
    """Return a human-readable representation of this operation.

    Returns:
        A string like ``"RX(0.5000, wires=[0])"`` or ``"CX(wires=[0, 1])"``.
    """
    params = self.parameters
    if params:
        param_str = ", ".join(
            (
                f"{float(v):.4f}"
                if isinstance(v, (float, np.floating, jnp.ndarray))
                else str(v)
            )
            for v in params
        )
        return f"{self.name}({param_str}, wires={self.wires})"
    return f"{self.name}(wires={self.wires})"

apply_to_density(rho, n_qubits) #

Apply this gate to a density matrix via \rho -> U\rho U\dagger.

The density matrix (shape (2**n, 2**n)) is treated as a rank-2n tensor with n "ket" axes (0..n-1) and n "bra" axes (n..2n-1). U acts on the ket half; U* acts on the bra half. Both contractions use the shared :func:_contract_and_restore helper, keeping the operation allocation-free with respect to building full unitaries.

Parameters:

Name Type Description Default
rho ndarray

Density matrix of shape (2**n_qubits, 2**n_qubits).

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
ndarray

Updated density matrix of shape (2**n_qubits, 2**n_qubits).

Source code in qml_essentials/operations.py
def apply_to_density(self, rho: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Apply this gate to a density matrix via \\rho -> U\\rho U\\dagger.

    The density matrix (shape ``(2**n, 2**n)``) is treated as a rank-*2n*
    tensor with n "ket" axes (0..n-1) and n "bra" axes (n..2n-1).
    U acts on the ket half; U* acts on the bra half.  Both contractions
    use the shared :func:`_contract_and_restore` helper, keeping the
    operation allocation-free with respect to building full unitaries.

    Args:
        rho: Density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        Updated density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
    """
    k = len(self.wires)
    U = self._gate_tensor(k)
    U_conj = jnp.conj(U)

    rho_t = rho.reshape((2,) * 2 * n_qubits)

    # Apply U to ket axes, U\\dagger to bra axes
    rho_t = _contract_and_restore(rho_t, U, k, self.wires)
    bra_wires = [w + n_qubits for w in self.wires]
    rho_t = _contract_and_restore(rho_t, U_conj, k, bra_wires)

    return rho_t.reshape(2**n_qubits, 2**n_qubits)

apply_to_state(state, n_qubits) #

Apply this gate to a statevector via tensor contraction.

The statevector (shape (2**n,)) is reshaped into a rank-n tensor of shape (2,)*n. The gate (shape (2**k, 2**k)) is reshaped to (2,)*2k and contracted against the k target wire axes.

Memory footprint is O(2**n) and the operation supports arbitrary k. The implementation is fully differentiable through JAX.

Parameters:

Name Type Description Default
state ndarray

Statevector of shape (2**n_qubits,).

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
ndarray

Updated statevector of shape (2**n_qubits,).

Source code in qml_essentials/operations.py
def apply_to_state(self, state: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Apply this gate to a statevector via tensor contraction.

    The statevector (shape ``(2**n,)``) is reshaped into a rank-n tensor
    of shape ``(2,)*n``.  The gate (shape ``(2**k, 2**k)``) is reshaped to
    ``(2,)*2k`` and contracted against the k target wire axes.

    Memory footprint is O(2**n) and the operation supports arbitrary k.
    The implementation is fully differentiable through JAX.

    Args:
        state: Statevector of shape ``(2**n_qubits,)``.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        Updated statevector of shape ``(2**n_qubits,)``.
    """
    k = len(self.wires)
    gate_tensor = self.matrix.reshape((2,) * 2 * k)
    psi = state.reshape((2,) * n_qubits)
    psi_out = _contract_and_restore(psi, gate_tensor, k, self.wires)
    return psi_out.reshape(2**n_qubits)

apply_to_state_tensor(psi, n_qubits) #

Apply this gate to a statevector already in tensor form.

Like :meth:apply_to_state but expects the state in rank-n tensor form (2,)*n and returns the result in the same form. This avoids the reshape calls at the per-gate level when the simulation loop keeps the state in tensor form throughout.

Parameters:

Name Type Description Default
psi ndarray

Statevector tensor of shape (2,)*n_qubits.

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
ndarray

Updated statevector tensor of shape (2,)*n_qubits.

Source code in qml_essentials/operations.py
def apply_to_state_tensor(self, psi: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Apply this gate to a statevector already in tensor form.

    Like :meth:`apply_to_state` but expects the state in rank-n tensor
    form ``(2,)*n`` and returns the result in the same form.  This avoids
    the ``reshape`` calls at the per-gate level when the simulation loop
    keeps the state in tensor form throughout.

    Args:
        psi: Statevector tensor of shape ``(2,)*n_qubits``.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        Updated statevector tensor of shape ``(2,)*n_qubits``.
    """
    k = len(self.wires)
    gate_tensor = self._gate_tensor(k)
    return _contract_and_restore(psi, gate_tensor, k, self.wires)

dagger() #

Return a new operation, the conjugate transpose (U\dagger) Usage inside a circuit function::

RX(0.5, wires=0).dagger()

Returns:

Type Description
Operation

A new :class:Operation with matrix U\dagger acting on the same wires.

Source code in qml_essentials/operations.py
def dagger(self) -> "Operation":
    """Return a new operation, the conjugate transpose (``U\\dagger``)
    Usage inside a circuit function::

        RX(0.5, wires=0).dagger()

    Returns:
        A new :class:`Operation` with matrix ``U\\dagger`` acting on the same wires.
    """
    mat = jnp.conj(self._matrix).T
    op = Operation(wires=self.wires, matrix=mat, record=False)

    self._update_tape_operation(op)

    return op

decompose() #

Decompose this operation into a list of more primitive operations.

The returned operations are created with record=False so the caller controls where they are placed. Reused e.g. by :meth:~qml_essentials.pauli.PauliCircuit.get_clifford_pauli_gates to express composite gates in terms of Clifford + Pauli-rotation primitives.

Returns:

Type Description
List[Operation]

List of :class:Operation instances equivalent to this gate.

Raises:

Type Description
NotImplementedError

If the gate has no decomposition (it is itself primitive).

Source code in qml_essentials/operations.py
def decompose(self) -> List["Operation"]:
    """Decompose this operation into a list of more primitive operations.

    The returned operations are created with ``record=False`` so the caller
    controls where they are placed.  Reused e.g. by
    :meth:`~qml_essentials.pauli.PauliCircuit.get_clifford_pauli_gates` to
    express composite gates in terms of Clifford + Pauli-rotation primitives.

    Returns:
        List of :class:`Operation` instances equivalent to this gate.

    Raises:
        NotImplementedError: If the gate has no decomposition (it is itself
            primitive).
    """
    raise NotImplementedError(
        f"{self.__class__.__name__} does not define a decomposition."
    )

lifted_matrix(n_qubits) #

Return the full 2**n x 2**n matrix embedding this gate.

Embeds the k-qubit gate matrix into the n-qubit Hilbert space by applying it to the identity matrix via :meth:apply_to_state. This is useful for computing Tr(O·\rho ) directly without vmap.

Parameters:

Name Type Description Default
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
ndarray

The (2**n, 2**n) matrix of this operation in the full space.

Source code in qml_essentials/operations.py
def lifted_matrix(self, n_qubits: int) -> jnp.ndarray:
    """Return the full ``2**n x 2**n`` matrix embedding this gate.

    Embeds the ``k``-qubit gate matrix into the ``n``-qubit Hilbert space
    by applying it to the identity matrix via :meth:`apply_to_state`.
    This is useful for computing ``Tr(O·\\rho )`` directly without vmap.

    Args:
        n_qubits: Total number of qubits in the circuit.

    Returns:
        The ``(2**n, 2**n)`` matrix of this operation in the full space.
    """
    dim = 2**n_qubits
    # Apply the gate to each basis vector (column of identity)
    return jax.vmap(lambda col: self.apply_to_state(col, n_qubits))(
        jnp.eye(dim, dtype=_cdtype())
    ).T

power(power) #

Return a new operation, the power (U^power) Usage inside a circuit function::

PauliX(wires=0).power(2)

Returns:

Type Description
Operation

A new :class:Operation with matrix U\dagger acting on the same wires.

Source code in qml_essentials/operations.py
def power(self, power) -> "Operation":
    """Return a new operation, the power (``U^power``)
    Usage inside a circuit function::

        PauliX(wires=0).power(2)

    Returns:
        A new :class:`Operation` with matrix ``U\\dagger`` acting on the same wires.
    """
    # TODO: support fractional powers
    mat = jnp.linalg.matrix_power(self._matrix, power)
    op = Operation(wires=self.wires, matrix=mat, record=False)

    self._update_tape_operation(op)

    return op

prod(*ops) #

Construct the generalized product (tensor or matrix) of this operation with others.

The resulting operation acts on the union of all wire sets. If the wire sets are disjoint, this is a Kronecker product. If the wire sets overlap, the corresponding matrices are multiplied.

Usage::

res = op1.prod(op2, op3)
# or
res = Operation.prod(op1, op2, op3)

Parameters:

Name Type Description Default
*ops Operation

Variable number of :class:Operation instances.

()

Returns:

Type Description
Operation

A new :class:Operation representing the composed operation.

Source code in qml_essentials/operations.py
def prod(self, *ops: "Operation") -> "Operation":
    """Construct the generalized product (tensor or matrix)
    of this operation with others.

    The resulting operation acts on the union of all wire sets.
    If the wire sets are disjoint, this is a Kronecker product.
    If the wire sets overlap, the corresponding matrices are multiplied.

    Usage::

        res = op1.prod(op2, op3)
        # or
        res = Operation.prod(op1, op2, op3)

    Args:
        *ops: Variable number of :class:`Operation` instances.

    Returns:
        A new :class:`Operation` representing the composed operation.
    """
    if not ops:
        return self

    all_ops = (self,) + ops
    all_wires = []
    for op in all_ops:
        for w in op.wires:
            if w not in all_wires:
                all_wires.append(w)

    n = len(all_wires)

    mat = _embed_matrix(all_ops[0].matrix, all_ops[0].wires, all_wires, n)
    for op in all_ops[1:]:
        mat_other = _embed_matrix(op.matrix, op.wires, all_wires, n)
        mat = mat @ mat_other

    op_names = "*".join(op.name for op in all_ops)
    return Operation(
        wires=all_wires, matrix=mat, name=f"Prod({op_names})", record=False
    )

Hermitian#

from qml_essentials.operations import Hermitian

Bases: Operation

A generic Hermitian observable or gate defined by an arbitrary matrix.

Example

obs = Hermitian(matrix=my_matrix, wires=0)

Source code in qml_essentials/operations.py
class Hermitian(Operation):
    """A generic Hermitian observable or gate defined by an arbitrary matrix.

    Example:
        >>> obs = Hermitian(matrix=my_matrix, wires=0)
    """

    def __init__(
        self,
        matrix: jnp.ndarray,
        wires: Union[int, List[int]] = 0,
        record: bool = True,
    ) -> None:
        """Initialise a Hermitian operator.

        Args:
            matrix: The Hermitian matrix defining this operator.
            wires: Qubit index or list of qubit indices this operator acts on.
            record: If ``True`` (default), record on the active tape.  Set to
                ``False`` when using the Hermitian purely as a Hamiltonian
                component (e.g. for time-dependent evolution).
        """
        super().__init__(
            wires=wires,
            matrix=jnp.asarray(matrix, dtype=_cdtype()),
            record=record,
        )

    def __rmul__(self, coeff_fn: Callable) -> "ParametrizedHamiltonian":
        """Support ``coeff_fn * Hermitian`` -> :class:`ParametrizedHamiltonian`.

        Args:
            coeff_fn (Callable): A callable ``(params, t) -> scalar`` giving the
                time-dependent coefficient.

        Returns:
            ParametrizedHamiltonian: A :class:`ParametrizedHamiltonian` pairing
                *coeff_fn* with this operator's matrix and wires.

        Raises:
            TypeError: If *coeff_fn* is not callable.
        """
        if not callable(coeff_fn):
            raise TypeError(
                f"Left operand of `* Hermitian` must be callable, got {type(coeff_fn)}"
            )
        return ParametrizedHamiltonian(terms=[(coeff_fn, self.matrix, self.wires)])

    def evolve(self, name: Optional[str] = None, **odeint_kwargs) -> Callable:
        """Return a gate factory for static evolution ``U = exp(-i t H)``.

        Thin delegator to :meth:`qml_essentials.evolution.Evolution.evolve`.

        Args:
            name: Optional name for the produced :class:`Operation`.
            **odeint_kwargs: Unused for static evolution (accepted for a
                uniform signature with :meth:`ParametrizedHamiltonian.evolve`).

        Returns:
            A callable gate factory ``(t, wires=0) -> Operation``.
        """
        from qml_essentials.evolution import Evolution  # deferred: circular import

        return Evolution.evolve(self, name=name, **odeint_kwargs)

__init__(matrix, wires=0, record=True) #

Initialise a Hermitian operator.

Parameters:

Name Type Description Default
matrix ndarray

The Hermitian matrix defining this operator.

required
wires Union[int, List[int]]

Qubit index or list of qubit indices this operator acts on.

0
record bool

If True (default), record on the active tape. Set to False when using the Hermitian purely as a Hamiltonian component (e.g. for time-dependent evolution).

True
Source code in qml_essentials/operations.py
def __init__(
    self,
    matrix: jnp.ndarray,
    wires: Union[int, List[int]] = 0,
    record: bool = True,
) -> None:
    """Initialise a Hermitian operator.

    Args:
        matrix: The Hermitian matrix defining this operator.
        wires: Qubit index or list of qubit indices this operator acts on.
        record: If ``True`` (default), record on the active tape.  Set to
            ``False`` when using the Hermitian purely as a Hamiltonian
            component (e.g. for time-dependent evolution).
    """
    super().__init__(
        wires=wires,
        matrix=jnp.asarray(matrix, dtype=_cdtype()),
        record=record,
    )

__rmul__(coeff_fn) #

Support coeff_fn * Hermitian -> :class:ParametrizedHamiltonian.

Parameters:

Name Type Description Default
coeff_fn Callable

A callable (params, t) -> scalar giving the time-dependent coefficient.

required

Returns:

Name Type Description
ParametrizedHamiltonian ParametrizedHamiltonian

A :class:ParametrizedHamiltonian pairing coeff_fn with this operator's matrix and wires.

Raises:

Type Description
TypeError

If coeff_fn is not callable.

Source code in qml_essentials/operations.py
def __rmul__(self, coeff_fn: Callable) -> "ParametrizedHamiltonian":
    """Support ``coeff_fn * Hermitian`` -> :class:`ParametrizedHamiltonian`.

    Args:
        coeff_fn (Callable): A callable ``(params, t) -> scalar`` giving the
            time-dependent coefficient.

    Returns:
        ParametrizedHamiltonian: A :class:`ParametrizedHamiltonian` pairing
            *coeff_fn* with this operator's matrix and wires.

    Raises:
        TypeError: If *coeff_fn* is not callable.
    """
    if not callable(coeff_fn):
        raise TypeError(
            f"Left operand of `* Hermitian` must be callable, got {type(coeff_fn)}"
        )
    return ParametrizedHamiltonian(terms=[(coeff_fn, self.matrix, self.wires)])

evolve(name=None, **odeint_kwargs) #

Return a gate factory for static evolution U = exp(-i t H).

Thin delegator to :meth:qml_essentials.evolution.Evolution.evolve.

Parameters:

Name Type Description Default
name Optional[str]

Optional name for the produced :class:Operation.

None
**odeint_kwargs

Unused for static evolution (accepted for a uniform signature with :meth:ParametrizedHamiltonian.evolve).

{}

Returns:

Type Description
Callable

A callable gate factory (t, wires=0) -> Operation.

Source code in qml_essentials/operations.py
def evolve(self, name: Optional[str] = None, **odeint_kwargs) -> Callable:
    """Return a gate factory for static evolution ``U = exp(-i t H)``.

    Thin delegator to :meth:`qml_essentials.evolution.Evolution.evolve`.

    Args:
        name: Optional name for the produced :class:`Operation`.
        **odeint_kwargs: Unused for static evolution (accepted for a
            uniform signature with :meth:`ParametrizedHamiltonian.evolve`).

    Returns:
        A callable gate factory ``(t, wires=0) -> Operation``.
    """
    from qml_essentials.evolution import Evolution  # deferred: circular import

    return Evolution.evolve(self, name=name, **odeint_kwargs)

Kraus Channel#

from qml_essentials.operations import KrausChannel

Bases: Operation

Base class for noise channels defined by a set of Kraus operators.

A Kraus channel \phi(\rho ) = \sigma_k K_k \rho K_k\dagger is the most general physical operation on a quantum state. For a pure unitary gate there is a single operator K_0 = U satisfying K_0\daggerK_0 = I; for noisy channels there are multiple operators.

Subclasses must implement :meth:kraus_matrices and return a list of JAX arrays. :meth:apply_to_state is intentionally left unimplemented: Kraus channels require a density-matrix representation and cannot be applied to a pure statevector in general.

Source code in qml_essentials/operations.py
class KrausChannel(Operation):
    """Base class for noise channels defined by a set of Kraus operators.

    A Kraus channel \\phi(\\rho ) = \\sigma_k K_k \\rho  K_k\\dagger
    is the most general physical
    operation on a quantum state.  For a pure unitary gate there is a single
    operator K_0 = U satisfying K_0\\daggerK_0 = I; for noisy channels there are
    multiple operators.

    Subclasses must implement :meth:`kraus_matrices` and return a list of JAX
    arrays.  :meth:`apply_to_state` is intentionally left unimplemented:
    Kraus channels require a density-matrix representation and cannot be
    applied to a pure statevector in general.
    """

    def kraus_matrices(self) -> List[jnp.ndarray]:
        """Return the list of Kraus operators for this channel.

        Returns:
            List of 2-D JAX arrays, each of shape ``(2**k, 2**k)`` where k
            is the number of target qubits.

        Raises:
            NotImplementedError: Subclasses must override this method.
        """
        raise NotImplementedError

    @property
    def matrix(self) -> jnp.ndarray:
        """Raises TypeError — noise channels have no single unitary matrix.

        Raises:
            TypeError: Always raised; use :meth:`apply_to_density` instead.
        """
        raise TypeError(
            f"{self.__class__.__name__} is a noise channel and has no single "
            "unitary matrix. Use apply_to_density() instead."
        )

    def apply_to_state(self, state: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Raises TypeError — noise channels require density-matrix simulation.

        Args:
            state: Statevector (unused).
            n_qubits: Number of qubits (unused).

        Raises:
            TypeError: Always raised; use ``execute(type='density')`` instead.
        """
        raise TypeError(
            f"{self.__class__.__name__} is a noise channel and cannot be "
            "applied to a pure statevector. Use execute(type='density') instead."
        )

    def apply_to_state_tensor(self, psi: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Raises TypeError — noise channels require density-matrix simulation."""
        raise TypeError(
            f"{self.__class__.__name__} is a noise channel and cannot be "
            "applied to a pure statevector. Use execute(type='density') instead."
        )

    def apply_to_density(self, rho: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
        """Apply
        \\phi(\\rho ) = \\sigma_k K_k \\rho  K_k\\dagger using tensor-contraction.

        Uses the shared :func:`_contract_and_restore` helper, summing the
        result over all Kraus operators.

        Args:
            rho: Density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            Updated density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
        """
        k = len(self.wires)
        dim = 2**n_qubits
        bra_wires = [w + n_qubits for w in self.wires]
        rho_out = jnp.zeros_like(rho)

        for K in self.kraus_matrices():
            K_t = K.reshape((2,) * 2 * k)
            K_conj_t = jnp.conj(K_t)
            rho_t = rho.reshape((2,) * 2 * n_qubits)
            rho_t = _contract_and_restore(rho_t, K_t, k, self.wires)
            rho_t = _contract_and_restore(rho_t, K_conj_t, k, bra_wires)
            rho_out = rho_out + rho_t.reshape(dim, dim)

        return rho_out

matrix property #

Raises TypeError — noise channels have no single unitary matrix.

Raises:

Type Description
TypeError

Always raised; use :meth:apply_to_density instead.

apply_to_density(rho, n_qubits) #

Apply \phi(\rho ) = \sigma_k K_k \rho K_k\dagger using tensor-contraction.

Uses the shared :func:_contract_and_restore helper, summing the result over all Kraus operators.

Parameters:

Name Type Description Default
rho ndarray

Density matrix of shape (2**n_qubits, 2**n_qubits).

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
ndarray

Updated density matrix of shape (2**n_qubits, 2**n_qubits).

Source code in qml_essentials/operations.py
def apply_to_density(self, rho: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Apply
    \\phi(\\rho ) = \\sigma_k K_k \\rho  K_k\\dagger using tensor-contraction.

    Uses the shared :func:`_contract_and_restore` helper, summing the
    result over all Kraus operators.

    Args:
        rho: Density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        Updated density matrix of shape ``(2**n_qubits, 2**n_qubits)``.
    """
    k = len(self.wires)
    dim = 2**n_qubits
    bra_wires = [w + n_qubits for w in self.wires]
    rho_out = jnp.zeros_like(rho)

    for K in self.kraus_matrices():
        K_t = K.reshape((2,) * 2 * k)
        K_conj_t = jnp.conj(K_t)
        rho_t = rho.reshape((2,) * 2 * n_qubits)
        rho_t = _contract_and_restore(rho_t, K_t, k, self.wires)
        rho_t = _contract_and_restore(rho_t, K_conj_t, k, bra_wires)
        rho_out = rho_out + rho_t.reshape(dim, dim)

    return rho_out

apply_to_state(state, n_qubits) #

Raises TypeError — noise channels require density-matrix simulation.

Parameters:

Name Type Description Default
state ndarray

Statevector (unused).

required
n_qubits int

Number of qubits (unused).

required

Raises:

Type Description
TypeError

Always raised; use execute(type='density') instead.

Source code in qml_essentials/operations.py
def apply_to_state(self, state: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Raises TypeError — noise channels require density-matrix simulation.

    Args:
        state: Statevector (unused).
        n_qubits: Number of qubits (unused).

    Raises:
        TypeError: Always raised; use ``execute(type='density')`` instead.
    """
    raise TypeError(
        f"{self.__class__.__name__} is a noise channel and cannot be "
        "applied to a pure statevector. Use execute(type='density') instead."
    )

apply_to_state_tensor(psi, n_qubits) #

Raises TypeError — noise channels require density-matrix simulation.

Source code in qml_essentials/operations.py
def apply_to_state_tensor(self, psi: jnp.ndarray, n_qubits: int) -> jnp.ndarray:
    """Raises TypeError — noise channels require density-matrix simulation."""
    raise TypeError(
        f"{self.__class__.__name__} is a noise channel and cannot be "
        "applied to a pure statevector. Use execute(type='density') instead."
    )

kraus_matrices() #

Return the list of Kraus operators for this channel.

Returns:

Type Description
List[ndarray]

List of 2-D JAX arrays, each of shape (2**k, 2**k) where k

List[ndarray]

is the number of target qubits.

Raises:

Type Description
NotImplementedError

Subclasses must override this method.

Source code in qml_essentials/operations.py
def kraus_matrices(self) -> List[jnp.ndarray]:
    """Return the list of Kraus operators for this channel.

    Returns:
        List of 2-D JAX arrays, each of shape ``(2**k, 2**k)`` where k
        is the number of target qubits.

    Raises:
        NotImplementedError: Subclasses must override this method.
    """
    raise NotImplementedError

Parametrized Hamiltonian#

from qml_essentials.operations import ParametrizedHamiltonian

A time-dependent Hamiltonian as a sum of coeff * Hermitian terms.

Mathematically::

H(t) = \sum_i f_i(params_i, t) * H_i

Construction is always done from an explicit list of (coeff_fn, H_mat, wires) triples passed as terms. The common single-term shorthand is the operator form coeff_fn * Hermitian(matrix, wires) (see :meth:Hermitian.__rmul__), which returns a one-term instance. Multi-term Hamiltonians are composed with + between :class:ParametrizedHamiltonian instances::

H1 = coeff_x * Hermitian(X, wires=0)
H2 = coeff_y * Hermitian(Y, wires=0)
H_td = H1 + H2

# evolve under the composite Hamiltonian; coeff_args is a list of
# parameter sets, one per term, in the order the terms were added:
H_td.evolve()([px, py], T=1.0)

Attributes:

Name Type Description
coeff_fns Tuple[Callable, ...]

Tuple of callables (params, t) -> scalar, one per term.

H_mats Tuple[ndarray, ...]

Tuple of static Hermitian matrices, one per term.

wires List[int]

Wires this Hamiltonian acts on (union across all terms; for now all terms are required to share the same wire set).

Source code in qml_essentials/operations.py
class ParametrizedHamiltonian:
    """A time-dependent Hamiltonian as a sum of ``coeff * Hermitian`` terms.

    Mathematically::

        H(t) = \\sum_i f_i(params_i, t) * H_i

    Construction is always done from an explicit list of
    ``(coeff_fn, H_mat, wires)`` triples passed as ``terms``.  The
    common single-term shorthand is the operator form
    ``coeff_fn * Hermitian(matrix, wires)`` (see
    :meth:`Hermitian.__rmul__`), which returns a one-term instance.
    Multi-term Hamiltonians are composed with ``+`` between
    :class:`ParametrizedHamiltonian` instances::

        H1 = coeff_x * Hermitian(X, wires=0)
        H2 = coeff_y * Hermitian(Y, wires=0)
        H_td = H1 + H2

        # evolve under the composite Hamiltonian; coeff_args is a list of
        # parameter sets, one per term, in the order the terms were added:
        H_td.evolve()([px, py], T=1.0)

    Attributes:
        coeff_fns: Tuple of callables ``(params, t) -> scalar``, one per term.
        H_mats: Tuple of static Hermitian matrices, one per term.
        wires: Wires this Hamiltonian acts on (union across all terms; for
            now all terms are required to share the same wire set).
    """

    def __init__(
        self,
        terms: List[Tuple[Callable, jnp.ndarray, Union[int, List[int]]]],
    ) -> None:
        """Build a (possibly multi-term) parametrized Hamiltonian.

        Args:
            terms: List of ``(coeff_fn, H_mat, wires)`` triples.  Use the
                ``coeff_fn * Hermitian(...)`` shorthand to build a
                one-term instance; combine instances with ``+`` to add
                terms.

        Raises:
            ValueError: If the term list is empty, or if terms act on
                differing wire sets (multi-wire broadcasting is
                deferred — see :mod:`jaqsi`), or if term matrices have
                incompatible shapes.
        """
        if len(terms) == 0:
            raise ValueError("ParametrizedHamiltonian needs at least one term.")

        # Normalise wires (single int -> [int]) and validate consistency.
        def _wlist(w):
            return [w] if isinstance(w, int) else list(w)

        first_wires = _wlist(terms[0][2])
        for _, _, w in terms[1:]:
            if _wlist(w) != first_wires:
                raise ValueError(
                    "All terms of a ParametrizedHamiltonian must currently "
                    "act on the same wires; got "
                    f"{_wlist(w)} vs. {first_wires}. "
                    "Multi-wire broadcasting across terms is not yet supported."
                )

        # Validate matrix shape compatibility across terms.
        first_dim = jnp.asarray(terms[0][1]).shape
        for _, H, _ in terms[1:]:
            if jnp.asarray(H).shape != first_dim:
                raise ValueError(
                    f"All term matrices must have the same shape; got "
                    f"{jnp.asarray(H).shape} vs. {first_dim}."
                )

        self._terms: Tuple[Tuple[Callable, jnp.ndarray, List[int]], ...] = tuple(
            (fn, jnp.asarray(H, dtype=_cdtype()), _wlist(w)) for fn, H, w in terms
        )
        self.wires: List[int] = list(first_wires)

    # --- term accessors -------------------------------------------------

    @property
    def coeff_fns(self) -> Tuple[Callable, ...]:
        """Tuple of coefficient functions, one per term."""
        return tuple(fn for fn, _, _ in self._terms)

    @property
    def H_mats(self) -> Tuple[jnp.ndarray, ...]:
        """Tuple of Hermitian matrices, one per term."""
        return tuple(H for _, H, _ in self._terms)

    @property
    def n_terms(self) -> int:
        """Number of terms in the Hamiltonian."""
        return len(self._terms)

    # --- composition ---------------------------------------------------

    def __add__(self, other: "ParametrizedHamiltonian") -> "ParametrizedHamiltonian":
        """Concatenate term lists: ``H = H1 + H2``."""
        if not isinstance(other, ParametrizedHamiltonian):
            return NotImplemented
        return ParametrizedHamiltonian(terms=list(self._terms) + list(other._terms))

    def __neg__(self) -> "ParametrizedHamiltonian":
        """Negate every coefficient: ``-H`` = sum of ``(-f_i) * H_i``."""
        new_terms = [
            ((lambda f: lambda p, t: -f(p, t))(fn), H, w) for fn, H, w in self._terms
        ]
        return ParametrizedHamiltonian(terms=new_terms)

    def __sub__(self, other: "ParametrizedHamiltonian") -> "ParametrizedHamiltonian":
        if not isinstance(other, ParametrizedHamiltonian):
            return NotImplemented
        return self + (-other)

    # --- evolution -----------------------------------------------------

    def evolve(self, name: Optional[str] = None, **odeint_kwargs) -> Callable:
        """Return a gate factory for time-dependent evolution.

        Solves ``dU/dt = -i [sum_i f_i(p_i, t) H_i] U``.  Thin delegator to
        :meth:`qml_essentials.evolution.Evolution.evolve`.

        Args:
            name: Optional name for the produced :class:`Operation`.
            **odeint_kwargs: Solver options forwarded to ``Evolution.evolve``
                (``atol``, ``rtol``, ``max_steps``, ``throw``, ``solver``,
                ``magnus_steps``).

        Returns:
            A callable gate factory ``(coeff_args, T) -> Operation``.
        """
        from qml_essentials.evolution import Evolution  # deferred: circular import

        return Evolution.evolve(self, name=name, **odeint_kwargs)

H_mats property #

Tuple of Hermitian matrices, one per term.

coeff_fns property #

Tuple of coefficient functions, one per term.

n_terms property #

Number of terms in the Hamiltonian.

__add__(other) #

Concatenate term lists: H = H1 + H2.

Source code in qml_essentials/operations.py
def __add__(self, other: "ParametrizedHamiltonian") -> "ParametrizedHamiltonian":
    """Concatenate term lists: ``H = H1 + H2``."""
    if not isinstance(other, ParametrizedHamiltonian):
        return NotImplemented
    return ParametrizedHamiltonian(terms=list(self._terms) + list(other._terms))

__init__(terms) #

Build a (possibly multi-term) parametrized Hamiltonian.

Parameters:

Name Type Description Default
terms List[Tuple[Callable, ndarray, Union[int, List[int]]]]

List of (coeff_fn, H_mat, wires) triples. Use the coeff_fn * Hermitian(...) shorthand to build a one-term instance; combine instances with + to add terms.

required

Raises:

Type Description
ValueError

If the term list is empty, or if terms act on differing wire sets (multi-wire broadcasting is deferred — see :mod:jaqsi), or if term matrices have incompatible shapes.

Source code in qml_essentials/operations.py
def __init__(
    self,
    terms: List[Tuple[Callable, jnp.ndarray, Union[int, List[int]]]],
) -> None:
    """Build a (possibly multi-term) parametrized Hamiltonian.

    Args:
        terms: List of ``(coeff_fn, H_mat, wires)`` triples.  Use the
            ``coeff_fn * Hermitian(...)`` shorthand to build a
            one-term instance; combine instances with ``+`` to add
            terms.

    Raises:
        ValueError: If the term list is empty, or if terms act on
            differing wire sets (multi-wire broadcasting is
            deferred — see :mod:`jaqsi`), or if term matrices have
            incompatible shapes.
    """
    if len(terms) == 0:
        raise ValueError("ParametrizedHamiltonian needs at least one term.")

    # Normalise wires (single int -> [int]) and validate consistency.
    def _wlist(w):
        return [w] if isinstance(w, int) else list(w)

    first_wires = _wlist(terms[0][2])
    for _, _, w in terms[1:]:
        if _wlist(w) != first_wires:
            raise ValueError(
                "All terms of a ParametrizedHamiltonian must currently "
                "act on the same wires; got "
                f"{_wlist(w)} vs. {first_wires}. "
                "Multi-wire broadcasting across terms is not yet supported."
            )

    # Validate matrix shape compatibility across terms.
    first_dim = jnp.asarray(terms[0][1]).shape
    for _, H, _ in terms[1:]:
        if jnp.asarray(H).shape != first_dim:
            raise ValueError(
                f"All term matrices must have the same shape; got "
                f"{jnp.asarray(H).shape} vs. {first_dim}."
            )

    self._terms: Tuple[Tuple[Callable, jnp.ndarray, List[int]], ...] = tuple(
        (fn, jnp.asarray(H, dtype=_cdtype()), _wlist(w)) for fn, H, w in terms
    )
    self.wires: List[int] = list(first_wires)

__neg__() #

Negate every coefficient: -H = sum of (-f_i) * H_i.

Source code in qml_essentials/operations.py
def __neg__(self) -> "ParametrizedHamiltonian":
    """Negate every coefficient: ``-H`` = sum of ``(-f_i) * H_i``."""
    new_terms = [
        ((lambda f: lambda p, t: -f(p, t))(fn), H, w) for fn, H, w in self._terms
    ]
    return ParametrizedHamiltonian(terms=new_terms)

evolve(name=None, **odeint_kwargs) #

Return a gate factory for time-dependent evolution.

Solves dU/dt = -i [sum_i f_i(p_i, t) H_i] U. Thin delegator to :meth:qml_essentials.evolution.Evolution.evolve.

Parameters:

Name Type Description Default
name Optional[str]

Optional name for the produced :class:Operation.

None
**odeint_kwargs

Solver options forwarded to Evolution.evolve (atol, rtol, max_steps, throw, solver, magnus_steps).

{}

Returns:

Type Description
Callable

A callable gate factory (coeff_args, T) -> Operation.

Source code in qml_essentials/operations.py
def evolve(self, name: Optional[str] = None, **odeint_kwargs) -> Callable:
    """Return a gate factory for time-dependent evolution.

    Solves ``dU/dt = -i [sum_i f_i(p_i, t) H_i] U``.  Thin delegator to
    :meth:`qml_essentials.evolution.Evolution.evolve`.

    Args:
        name: Optional name for the produced :class:`Operation`.
        **odeint_kwargs: Solver options forwarded to ``Evolution.evolve``
            (``atol``, ``rtol``, ``max_steps``, ``throw``, ``solver``,
            ``magnus_steps``).

    Returns:
        A callable gate factory ``(coeff_args, T) -> Operation``.
    """
    from qml_essentials.evolution import Evolution  # deferred: circular import

    return Evolution.evolve(self, name=name, **odeint_kwargs)

Pauli Rotation#

from qml_essentials.operations import PauliRot

Bases: Operation

Multi-qubit Pauli rotation: exp(-i \theta/2 P) for a Pauli word P.

The Pauli word is given as a string of 'I', 'X', 'Y', 'Z' characters (one per qubit). The rotation matrix is computed as cos(\theta/2) I - i sin(\theta/2) P where P is the tensor product of the corresponding single-qubit Pauli matrices.

Example::

PauliRot(0.5, "XY", wires=[0, 1])
Source code in qml_essentials/operations.py
class PauliRot(Operation):
    """Multi-qubit Pauli rotation: exp(-i \\theta/2 P) for a Pauli word P.

    The Pauli word is given as a string of ``'I'``, ``'X'``, ``'Y'``, ``'Z'``
    characters (one per qubit).  The rotation matrix is computed as
    ``cos(\\theta/2) I - i sin(\\theta/2) P`` where *P* is the tensor product of the
    corresponding single-qubit Pauli matrices.

    Example::

        PauliRot(0.5, "XY", wires=[0, 1])
    """

    _param_names = ("theta",)

    # Map from character to 2x2 matrix (canonical single source of truth)
    _PAULI_MAP = _PAULI_MATRICES

    def __init__(
        self, theta: float, pauli_word: str, wires: Union[int, List[int]] = 0, **kwargs
    ) -> None:
        """Initialise a PauliRot gate.

        Args:
            theta: Rotation angle in radians.
            pauli_word: A string of ``'I'``, ``'X'``, ``'Y'``, ``'Z'``
                characters specifying the Pauli tensor product.
            wires: Qubit index or list of qubit indices this gate acts on.
        """
        from functools import reduce as _reduce

        self.theta = theta
        self.pauli_word = pauli_word

        pauli_matrices = [self._PAULI_MAP[c] for c in pauli_word]
        P = _reduce(jnp.kron, pauli_matrices)
        dim = P.shape[0]
        mat = (
            jnp.cos(theta / 2) * jnp.eye(dim, dtype=_cdtype())
            - 1j * jnp.sin(theta / 2) * P
        )
        super().__init__(wires=wires, matrix=mat, **kwargs)

    def generator(self) -> Operation:
        """Return the generator Pauli tensor product as an :class:`Operation`.

        The generator of ``PauliRot(\\theta, word, wires)`` is the tensor product
        of single-qubit Pauli matrices specified by *word*.  The returned
        :class:`Hermitian` wraps that matrix and the gate's wires.

        Returns:
            :class:`Hermitian` operation representing the Pauli tensor product.
        """
        from functools import reduce as _reduce

        pauli_matrices = [self._PAULI_MAP[c] for c in self.pauli_word]
        P = _reduce(jnp.kron, pauli_matrices)
        return Hermitian(matrix=P, wires=self.wires, record=False)

__init__(theta, pauli_word, wires=0, **kwargs) #

Initialise a PauliRot gate.

Parameters:

Name Type Description Default
theta float

Rotation angle in radians.

required
pauli_word str

A string of 'I', 'X', 'Y', 'Z' characters specifying the Pauli tensor product.

required
wires Union[int, List[int]]

Qubit index or list of qubit indices this gate acts on.

0
Source code in qml_essentials/operations.py
def __init__(
    self, theta: float, pauli_word: str, wires: Union[int, List[int]] = 0, **kwargs
) -> None:
    """Initialise a PauliRot gate.

    Args:
        theta: Rotation angle in radians.
        pauli_word: A string of ``'I'``, ``'X'``, ``'Y'``, ``'Z'``
            characters specifying the Pauli tensor product.
        wires: Qubit index or list of qubit indices this gate acts on.
    """
    from functools import reduce as _reduce

    self.theta = theta
    self.pauli_word = pauli_word

    pauli_matrices = [self._PAULI_MAP[c] for c in pauli_word]
    P = _reduce(jnp.kron, pauli_matrices)
    dim = P.shape[0]
    mat = (
        jnp.cos(theta / 2) * jnp.eye(dim, dtype=_cdtype())
        - 1j * jnp.sin(theta / 2) * P
    )
    super().__init__(wires=wires, matrix=mat, **kwargs)

generator() #

Return the generator Pauli tensor product as an :class:Operation.

The generator of PauliRot(\theta, word, wires) is the tensor product of single-qubit Pauli matrices specified by word. The returned :class:Hermitian wraps that matrix and the gate's wires.

Returns:

Type Description
Operation

class:Hermitian operation representing the Pauli tensor product.

Source code in qml_essentials/operations.py
def generator(self) -> Operation:
    """Return the generator Pauli tensor product as an :class:`Operation`.

    The generator of ``PauliRot(\\theta, word, wires)`` is the tensor product
    of single-qubit Pauli matrices specified by *word*.  The returned
    :class:`Hermitian` wraps that matrix and the gate's wires.

    Returns:
        :class:`Hermitian` operation representing the Pauli tensor product.
    """
    from functools import reduce as _reduce

    pauli_matrices = [self._PAULI_MAP[c] for c in self.pauli_word]
    P = _reduce(jnp.kron, pauli_matrices)
    return Hermitian(matrix=P, wires=self.wires, record=False)

Pauli Word#

from qml_essentials.operations import PauliWord

Symbolic n-qubit Pauli operator in the stabilizer-tableau (symplectic) representation.

A Pauli word is stored as

.. math:: P = i^{\text{phase}} \prod_{q} X_q^{x_q} Z_q^{z_q},

with bit arrays x, z \in \{0, 1\}^n and an integer phase taken mod 4 (tracking the scalar i^{phase}). Single-qubit Paulis map as I=(0,0), X=(1,0), Z=(0,1), Y=(1,1) (since Y = i X Z).

This replaces the matrix-based Clifford conjugation (:func:evolve_pauli_with_clifford + :func:pauli_decompose) with O(n) symbolic updates, and is shared by both :class:~qml_essentials.pauli.PauliCircuit and the Fourier-tree algorithm.

All operations use NumPy (integer arithmetic), not JAX — this is symbolic bookkeeping, not numeric computation.

Source code in qml_essentials/operations.py
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
class PauliWord:
    r"""Symbolic n-qubit Pauli operator in the stabilizer-tableau (symplectic)
    representation.

    A Pauli word is stored as

    .. math::
        P = i^{\text{phase}} \prod_{q} X_q^{x_q} Z_q^{z_q},

    with bit arrays ``x, z \in \{0, 1\}^n`` and an integer ``phase`` taken mod 4
    (tracking the scalar ``i^{phase}``).  Single-qubit Paulis map as
    ``I=(0,0)``, ``X=(1,0)``, ``Z=(0,1)``, ``Y=(1,1)`` (since ``Y = i X Z``).

    This replaces the matrix-based Clifford conjugation
    (:func:`evolve_pauli_with_clifford` + :func:`pauli_decompose`) with O(n)
    symbolic updates, and is shared by both
    :class:`~qml_essentials.pauli.PauliCircuit` and the Fourier-tree algorithm.

    All operations use NumPy (integer arithmetic), not JAX — this is symbolic
    bookkeeping, not numeric computation.
    """

    __slots__ = ("x", "z", "phase")

    def __init__(self, x: np.ndarray, z: np.ndarray, phase: int = 0) -> None:
        """Initialise a Pauli word.

        Args:
            x: Integer/boolean array of X-component bits, length ``n_qubits``.
            z: Integer/boolean array of Z-component bits, length ``n_qubits``.
            phase: Exponent of the global ``i^{phase}`` scalar (taken mod 4).
        """
        self.x = np.asarray(x, dtype=np.int8) & 1
        self.z = np.asarray(z, dtype=np.int8) & 1
        self.phase = int(phase) % 4

    # ---- constructors ---------------------------------------------------
    @classmethod
    def identity(cls, n_qubits: int) -> "PauliWord":
        """Return the identity Pauli word on *n_qubits*."""
        z = np.zeros(n_qubits, dtype=np.int8)
        return cls(z.copy(), z, 0)

    @classmethod
    def from_pauli_string(
        cls, pauli_string: str, wires: List[int], n_qubits: int
    ) -> "PauliWord":
        """Build a Pauli word from a Pauli string and its wires.

        Args:
            pauli_string: String over ``{'I', 'X', 'Y', 'Z'}``; one character
                per entry of *wires*.
            wires: Qubit indices the characters act on.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            The corresponding :class:`PauliWord`.
        """
        x = np.zeros(n_qubits, dtype=np.int8)
        z = np.zeros(n_qubits, dtype=np.int8)
        n_y = 0
        for ch, w in zip(pauli_string, wires):
            xb, zb = _LABEL_TO_XZ[ch]
            x[w] = xb
            z[w] = zb
            if ch == "Y":
                n_y += 1
        # Each Y contributes a factor i (Y = i X Z), accumulated into phase.
        return cls(x, z, n_y % 4)

    @classmethod
    def from_operation(cls, op: "Operation", n_qubits: int) -> "PauliWord":
        """Build a Pauli word from a Pauli-like operation.

        Supports :class:`PauliX`/:class:`PauliY`/:class:`PauliZ`/:class:`Id`,
        :class:`PauliRot` (via its ``pauli_word``), and any operation carrying a
        ``_pauli_label`` (e.g. produced by :func:`pauli_decompose`) or otherwise
        decomposable by :func:`pauli_string_from_operation`.

        Args:
            op: The operation to convert.
            n_qubits: Total number of qubits in the circuit.

        Returns:
            The corresponding :class:`PauliWord`.
        """
        # Cached symbolic word (e.g. attached to a Clifford-evolved observable).
        cached = getattr(op, "_pauli_word", None)
        if isinstance(cached, PauliWord) and cached.n_qubits == n_qubits:
            return cached
        if isinstance(op, PauliRot):
            return cls.from_pauli_string(op.pauli_word, op.wires, n_qubits)
        # Single-qubit Pauli rotations: generator is the corresponding Pauli.
        rot_to_label = {"RX": "X", "RY": "Y", "RZ": "Z"}
        if op.name in rot_to_label:
            return cls.from_pauli_string(rot_to_label[op.name], op.wires, n_qubits)
        name_to_label = {"PauliX": "X", "PauliY": "Y", "PauliZ": "Z", "I": "I"}
        if op.name in name_to_label:
            return cls.from_pauli_string(name_to_label[op.name], op.wires, n_qubits)
        pauli_str = pauli_string_from_operation(op)
        return cls.from_pauli_string(pauli_str, op.wires, n_qubits)

    @property
    def n_qubits(self) -> int:
        """Number of qubits this Pauli word spans."""
        return self.x.shape[0]

    @property
    def xy_mask(self) -> np.ndarray:
        """Boolean mask of qubits carrying an X or Y (i.e. ``x`` bits set)."""
        return self.x.astype(bool)

    @property
    def is_diagonal(self) -> bool:
        """Whether the word is diagonal (only I/Z, i.e. no X component)."""
        return not bool(self.x.any())

    # ---- algebra --------------------------------------------------------
    def commutes_with(self, other: "PauliWord") -> bool:
        """Return whether this Pauli word commutes with *other*.

        Two Paulis commute iff their symplectic inner product vanishes mod 2.
        """
        sp = int(np.dot(self.x, other.z) + np.dot(self.z, other.x)) % 2
        return sp == 0

    def compose(self, other: "PauliWord") -> "PauliWord":
        r"""Return the operator product ``self @ other`` as a new Pauli word.

        Uses the exact symplectic product rule

        .. math::
            (X^{x_1} Z^{z_1})(X^{x_2} Z^{z_2})
              = (-1)^{z_1 \cdot x_2}\, X^{x_1 \oplus x_2} Z^{z_1 \oplus z_2},

        combined with the ``i^{phase}`` scalars (``-1 = i^2``).
        """
        new_x = self.x ^ other.x
        new_z = self.z ^ other.z
        cross = int(np.dot(self.z, other.x))
        new_phase = (self.phase + other.phase + 2 * cross) % 4
        return PauliWord(new_x, new_z, new_phase)

    def conjugate_by_clifford(
        self, clifford: "Operation", adjoint_left: bool = False
    ) -> "PauliWord":
        r"""Return the Clifford conjugation of this Pauli word.

        Computes ``C P C^\dagger`` (``adjoint_left=False``) or
        ``C^\dagger P C`` (``adjoint_left=True``) symbolically, where *C* is one
        of the supported Clifford gates ``H, S, CX, CZ`` or a Pauli gate
        ``PauliX/PauliY/PauliZ``.

        The conjugation is realised by substituting the images of the
        single-qubit generators ``X_q`` and ``Z_q`` and re-composing in canonical
        order, so all phases are tracked exactly by :meth:`compose`.

        Args:
            clifford: The Clifford operation to conjugate by.
            adjoint_left: If ``True`` compute ``C^\dagger P C``; else
                ``C P C^\dagger``.

        Returns:
            The conjugated :class:`PauliWord`.

        Raises:
            NotImplementedError: If *clifford* is not a supported gate.
        """
        n = self.n_qubits
        name = clifford.name

        # Pauli gates: conjugation is just  Q P Q  (Q is Hermitian => Q^dagger=Q).
        if name in ("PauliX", "PauliY", "PauliZ"):
            q = PauliWord.from_operation(clifford, n)
            return q.compose(self).compose(q)

        try:
            images_x, images_z = self._clifford_generator_images(
                name, list(clifford.wires), adjoint_left, n
            )
        except NotImplementedError:
            # Any other Clifford (e.g. CY): fall back to the (exact) matrix
            # conjugation, which works for arbitrary Cliffords at O(2^n) cost.
            return self._conjugate_via_matrix(clifford, adjoint_left)

        result = PauliWord.identity(n)
        result.phase = self.phase
        for q in range(n):
            if self.x[q]:
                result = result.compose(images_x[q])
            if self.z[q]:
                result = result.compose(images_z[q])
        return result

    def _conjugate_via_matrix(
        self, clifford: "Operation", adjoint_left: bool
    ) -> "PauliWord":
        """Matrix-based Clifford conjugation fallback (exact, any Clifford).

        Used by :meth:`conjugate_by_clifford` for Cliffords without a symbolic
        tableau rule.  Reuses :meth:`to_matrix` / :meth:`from_matrix` and the
        gate's dense matrix.
        """
        n = self.n_qubits
        C = _embed_matrix(clifford.matrix, clifford.wires, list(range(n)), n)
        Cd = jnp.conj(C).T
        mat = self.to_matrix()
        result = (Cd @ mat @ C) if adjoint_left else (C @ mat @ Cd)
        return PauliWord.from_matrix(result)

    @staticmethod
    def _clifford_generator_images(
        name: str, wires: List[int], adjoint_left: bool, n: int
    ) -> Tuple[List["PauliWord"], List["PauliWord"]]:
        """Images of single-qubit generators ``X_q``/``Z_q`` under a Clifford.

        Returns two lists (indexed by qubit) of :class:`PauliWord` giving
        ``C X_q C^\\dagger`` and ``C Z_q C^\\dagger`` (or the adjoint direction).
        Qubits outside the gate support map to themselves.
        """

        def single(label: str, q: int) -> "PauliWord":
            return PauliWord.from_pauli_string(label, [q], n)

        images_x = [single("X", q) for q in range(n)]
        images_z = [single("Z", q) for q in range(n)]

        if name == "H":
            w = wires[0]
            images_x[w] = single("Z", w)  # H X H = Z
            images_z[w] = single("X", w)  # H Z H = X
        elif name == "S":
            w = wires[0]
            if adjoint_left:  # S^dagger X S = -Y ; S^dagger Z S = Z
                images_x[w] = PauliWord.from_pauli_string("Y", [w], n).compose(
                    PauliWord(np.zeros(n, np.int8), np.zeros(n, np.int8), 2)
                )
            else:  # S X S^dagger = Y ; S Z S^dagger = Z
                images_x[w] = single("Y", w)
            # images_z[w] unchanged (Z)
        elif name == "CX":
            c, t = wires
            images_x[c] = single("X", c).compose(single("X", t))  # X_c -> X_c X_t
            images_z[t] = single("Z", c).compose(single("Z", t))  # Z_t -> Z_c Z_t
            # X_t -> X_t and Z_c -> Z_c unchanged ; CX is Hermitian
        elif name == "CZ":
            c, t = wires
            images_x[c] = single("X", c).compose(single("Z", t))  # X_c -> X_c Z_t
            images_x[t] = single("Z", c).compose(single("X", t))  # X_t -> Z_c X_t
            # Z_c, Z_t unchanged ; CZ is Hermitian
        elif name == "SWAP":
            a, b = wires
            images_x[a], images_x[b] = single("X", b), single("X", a)  # swap supports
            images_z[a], images_z[b] = single("Z", b), single("Z", a)
        else:
            raise NotImplementedError(f"No symbolic Clifford rule for gate '{name}'.")
        return images_x, images_z

    # ---- expectation / conversions -------------------------------------
    def zero_expectation(self) -> complex:
        r"""Return ``<0|P|0>`` for the all-zero computational basis state.

        Non-zero only for diagonal words (I/Z only), in which case it equals the
        global phase ``i^{phase}``.
        """
        if not self.is_diagonal:
            return 0.0 + 0.0j
        return complex(1j**self.phase)

    def to_pauli_string(self) -> str:
        """Return the bare Pauli string (ignoring the global phase)."""
        return "".join(
            _XZ_TO_LABEL[(int(self.x[q]), int(self.z[q]))] for q in range(self.n_qubits)
        )

    def leading_phase(self) -> complex:
        r"""Return the scalar ``c`` such that ``P = c * (bare Pauli string)``.

        Because the bare string already contains ``i^{n_Y}`` from its Y factors,
        ``c = i^{phase - n_Y}``.
        """
        n_y = int(((self.x == 1) & (self.z == 1)).sum())
        return complex(1j ** ((self.phase - n_y) % 4))

    def to_pauli_string_and_phase(self) -> Tuple[str, complex]:
        """Return ``(bare Pauli string, leading scalar phase)``."""
        return self.to_pauli_string(), self.leading_phase()

    def to_matrix(self) -> jnp.ndarray:
        r"""Return the dense operator matrix ``i^{phase} \bigotimes_q X^{x_q} Z^{z_q}``.

        The per-qubit factor is the symplectic product ``X^{x} Z^{z}`` (so the
        ``(1, 1)`` factor is ``XZ = -iY``; the ``Y``-vs-``XZ`` phase is carried by
        ``i^{phase}``).  Inverse of :meth:`from_matrix`.
        """
        ident = _PAULI_MATRICES["I"]
        xmat = _PAULI_MATRICES["X"]
        zmat = _PAULI_MATRICES["Z"]
        mat = jnp.array([[1.0 + 0.0j]], dtype=_cdtype())
        for q in range(self.n_qubits):
            factor = (xmat if self.x[q] else ident) @ (zmat if self.z[q] else ident)
            mat = jnp.kron(mat, factor)
        return (1j**self.phase) * mat

    @classmethod
    def from_matrix(cls, matrix: jnp.ndarray) -> "PauliWord":
        r"""Build a Pauli word from a matrix that is a single (signed) Pauli.

        Recovers the dominant Pauli string and folds its (unit) coefficient
        ``c = i^k`` into the word's phase.  Intended for matrices that are
        exactly a Pauli up to a ``{\pm 1, \pm i}`` scalar (e.g. the result of
        Clifford conjugation of a Pauli); the dominant term is returned for
        general inputs.

        Args:
            matrix: A ``(2**n, 2**n)`` matrix proportional to a Pauli string.

        Returns:
            The corresponding :class:`PauliWord` on ``n`` qubits.
        """
        coeff, label = _dominant_pauli_label(matrix)
        n = len(label)
        word = cls.from_pauli_string(label, list(range(n)), n)
        # Fold the unit coefficient  c = i^k  into the phase.
        k = int(round(np.angle(complex(coeff)) / (np.pi / 2))) % 4
        word.phase = (word.phase + k) % 4
        return word

    def to_list_repr(self) -> np.ndarray:
        """Return the legacy int list representation (I=-1, X=0, Y=1, Z=2)."""
        out = np.full(self.n_qubits, -1, dtype=int)
        for q in range(self.n_qubits):
            label = _XZ_TO_LABEL[(int(self.x[q]), int(self.z[q]))]
            out[q] = {"I": -1, "X": 0, "Y": 1, "Z": 2}[label]
        return out

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, PauliWord):
            return NotImplemented
        return (
            self.phase == other.phase
            and np.array_equal(self.x, other.x)
            and np.array_equal(self.z, other.z)
        )

    def __repr__(self) -> str:
        phase_str = {0: "+", 1: "+i", 2: "-", 3: "-i"}[self.phase]
        return f"PauliWord({phase_str}{self.to_pauli_string()})"

is_diagonal property #

Whether the word is diagonal (only I/Z, i.e. no X component).

n_qubits property #

Number of qubits this Pauli word spans.

xy_mask property #

Boolean mask of qubits carrying an X or Y (i.e. x bits set).

__init__(x, z, phase=0) #

Initialise a Pauli word.

Parameters:

Name Type Description Default
x ndarray

Integer/boolean array of X-component bits, length n_qubits.

required
z ndarray

Integer/boolean array of Z-component bits, length n_qubits.

required
phase int

Exponent of the global i^{phase} scalar (taken mod 4).

0
Source code in qml_essentials/operations.py
def __init__(self, x: np.ndarray, z: np.ndarray, phase: int = 0) -> None:
    """Initialise a Pauli word.

    Args:
        x: Integer/boolean array of X-component bits, length ``n_qubits``.
        z: Integer/boolean array of Z-component bits, length ``n_qubits``.
        phase: Exponent of the global ``i^{phase}`` scalar (taken mod 4).
    """
    self.x = np.asarray(x, dtype=np.int8) & 1
    self.z = np.asarray(z, dtype=np.int8) & 1
    self.phase = int(phase) % 4

commutes_with(other) #

Return whether this Pauli word commutes with other.

Two Paulis commute iff their symplectic inner product vanishes mod 2.

Source code in qml_essentials/operations.py
def commutes_with(self, other: "PauliWord") -> bool:
    """Return whether this Pauli word commutes with *other*.

    Two Paulis commute iff their symplectic inner product vanishes mod 2.
    """
    sp = int(np.dot(self.x, other.z) + np.dot(self.z, other.x)) % 2
    return sp == 0

compose(other) #

Return the operator product self @ other as a new Pauli word.

Uses the exact symplectic product rule

.. math:: (X^{x_1} Z^{z_1})(X^{x_2} Z^{z_2}) = (-1)^{z_1 \cdot x_2}\, X^{x_1 \oplus x_2} Z^{z_1 \oplus z_2},

combined with the i^{phase} scalars (-1 = i^2).

Source code in qml_essentials/operations.py
def compose(self, other: "PauliWord") -> "PauliWord":
    r"""Return the operator product ``self @ other`` as a new Pauli word.

    Uses the exact symplectic product rule

    .. math::
        (X^{x_1} Z^{z_1})(X^{x_2} Z^{z_2})
          = (-1)^{z_1 \cdot x_2}\, X^{x_1 \oplus x_2} Z^{z_1 \oplus z_2},

    combined with the ``i^{phase}`` scalars (``-1 = i^2``).
    """
    new_x = self.x ^ other.x
    new_z = self.z ^ other.z
    cross = int(np.dot(self.z, other.x))
    new_phase = (self.phase + other.phase + 2 * cross) % 4
    return PauliWord(new_x, new_z, new_phase)

conjugate_by_clifford(clifford, adjoint_left=False) #

Return the Clifford conjugation of this Pauli word.

Computes C P C^\dagger (adjoint_left=False) or C^\dagger P C (adjoint_left=True) symbolically, where C is one of the supported Clifford gates H, S, CX, CZ or a Pauli gate PauliX/PauliY/PauliZ.

The conjugation is realised by substituting the images of the single-qubit generators X_q and Z_q and re-composing in canonical order, so all phases are tracked exactly by :meth:compose.

Parameters:

Name Type Description Default
clifford Operation

The Clifford operation to conjugate by.

required
adjoint_left bool

If True compute C^\dagger P C; else C P C^\dagger.

False

Returns:

Type Description
PauliWord

The conjugated :class:PauliWord.

Raises:

Type Description
NotImplementedError

If clifford is not a supported gate.

Source code in qml_essentials/operations.py
def conjugate_by_clifford(
    self, clifford: "Operation", adjoint_left: bool = False
) -> "PauliWord":
    r"""Return the Clifford conjugation of this Pauli word.

    Computes ``C P C^\dagger`` (``adjoint_left=False``) or
    ``C^\dagger P C`` (``adjoint_left=True``) symbolically, where *C* is one
    of the supported Clifford gates ``H, S, CX, CZ`` or a Pauli gate
    ``PauliX/PauliY/PauliZ``.

    The conjugation is realised by substituting the images of the
    single-qubit generators ``X_q`` and ``Z_q`` and re-composing in canonical
    order, so all phases are tracked exactly by :meth:`compose`.

    Args:
        clifford: The Clifford operation to conjugate by.
        adjoint_left: If ``True`` compute ``C^\dagger P C``; else
            ``C P C^\dagger``.

    Returns:
        The conjugated :class:`PauliWord`.

    Raises:
        NotImplementedError: If *clifford* is not a supported gate.
    """
    n = self.n_qubits
    name = clifford.name

    # Pauli gates: conjugation is just  Q P Q  (Q is Hermitian => Q^dagger=Q).
    if name in ("PauliX", "PauliY", "PauliZ"):
        q = PauliWord.from_operation(clifford, n)
        return q.compose(self).compose(q)

    try:
        images_x, images_z = self._clifford_generator_images(
            name, list(clifford.wires), adjoint_left, n
        )
    except NotImplementedError:
        # Any other Clifford (e.g. CY): fall back to the (exact) matrix
        # conjugation, which works for arbitrary Cliffords at O(2^n) cost.
        return self._conjugate_via_matrix(clifford, adjoint_left)

    result = PauliWord.identity(n)
    result.phase = self.phase
    for q in range(n):
        if self.x[q]:
            result = result.compose(images_x[q])
        if self.z[q]:
            result = result.compose(images_z[q])
    return result

from_matrix(matrix) classmethod #

Build a Pauli word from a matrix that is a single (signed) Pauli.

Recovers the dominant Pauli string and folds its (unit) coefficient c = i^k into the word's phase. Intended for matrices that are exactly a Pauli up to a {\pm 1, \pm i} scalar (e.g. the result of Clifford conjugation of a Pauli); the dominant term is returned for general inputs.

Parameters:

Name Type Description Default
matrix ndarray

A (2**n, 2**n) matrix proportional to a Pauli string.

required

Returns:

Type Description
PauliWord

The corresponding :class:PauliWord on n qubits.

Source code in qml_essentials/operations.py
@classmethod
def from_matrix(cls, matrix: jnp.ndarray) -> "PauliWord":
    r"""Build a Pauli word from a matrix that is a single (signed) Pauli.

    Recovers the dominant Pauli string and folds its (unit) coefficient
    ``c = i^k`` into the word's phase.  Intended for matrices that are
    exactly a Pauli up to a ``{\pm 1, \pm i}`` scalar (e.g. the result of
    Clifford conjugation of a Pauli); the dominant term is returned for
    general inputs.

    Args:
        matrix: A ``(2**n, 2**n)`` matrix proportional to a Pauli string.

    Returns:
        The corresponding :class:`PauliWord` on ``n`` qubits.
    """
    coeff, label = _dominant_pauli_label(matrix)
    n = len(label)
    word = cls.from_pauli_string(label, list(range(n)), n)
    # Fold the unit coefficient  c = i^k  into the phase.
    k = int(round(np.angle(complex(coeff)) / (np.pi / 2))) % 4
    word.phase = (word.phase + k) % 4
    return word

from_operation(op, n_qubits) classmethod #

Build a Pauli word from a Pauli-like operation.

Supports :class:PauliX/:class:PauliY/:class:PauliZ/:class:Id, :class:PauliRot (via its pauli_word), and any operation carrying a _pauli_label (e.g. produced by :func:pauli_decompose) or otherwise decomposable by :func:pauli_string_from_operation.

Parameters:

Name Type Description Default
op Operation

The operation to convert.

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
PauliWord

The corresponding :class:PauliWord.

Source code in qml_essentials/operations.py
@classmethod
def from_operation(cls, op: "Operation", n_qubits: int) -> "PauliWord":
    """Build a Pauli word from a Pauli-like operation.

    Supports :class:`PauliX`/:class:`PauliY`/:class:`PauliZ`/:class:`Id`,
    :class:`PauliRot` (via its ``pauli_word``), and any operation carrying a
    ``_pauli_label`` (e.g. produced by :func:`pauli_decompose`) or otherwise
    decomposable by :func:`pauli_string_from_operation`.

    Args:
        op: The operation to convert.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        The corresponding :class:`PauliWord`.
    """
    # Cached symbolic word (e.g. attached to a Clifford-evolved observable).
    cached = getattr(op, "_pauli_word", None)
    if isinstance(cached, PauliWord) and cached.n_qubits == n_qubits:
        return cached
    if isinstance(op, PauliRot):
        return cls.from_pauli_string(op.pauli_word, op.wires, n_qubits)
    # Single-qubit Pauli rotations: generator is the corresponding Pauli.
    rot_to_label = {"RX": "X", "RY": "Y", "RZ": "Z"}
    if op.name in rot_to_label:
        return cls.from_pauli_string(rot_to_label[op.name], op.wires, n_qubits)
    name_to_label = {"PauliX": "X", "PauliY": "Y", "PauliZ": "Z", "I": "I"}
    if op.name in name_to_label:
        return cls.from_pauli_string(name_to_label[op.name], op.wires, n_qubits)
    pauli_str = pauli_string_from_operation(op)
    return cls.from_pauli_string(pauli_str, op.wires, n_qubits)

from_pauli_string(pauli_string, wires, n_qubits) classmethod #

Build a Pauli word from a Pauli string and its wires.

Parameters:

Name Type Description Default
pauli_string str

String over {'I', 'X', 'Y', 'Z'}; one character per entry of wires.

required
wires List[int]

Qubit indices the characters act on.

required
n_qubits int

Total number of qubits in the circuit.

required

Returns:

Type Description
PauliWord

The corresponding :class:PauliWord.

Source code in qml_essentials/operations.py
@classmethod
def from_pauli_string(
    cls, pauli_string: str, wires: List[int], n_qubits: int
) -> "PauliWord":
    """Build a Pauli word from a Pauli string and its wires.

    Args:
        pauli_string: String over ``{'I', 'X', 'Y', 'Z'}``; one character
            per entry of *wires*.
        wires: Qubit indices the characters act on.
        n_qubits: Total number of qubits in the circuit.

    Returns:
        The corresponding :class:`PauliWord`.
    """
    x = np.zeros(n_qubits, dtype=np.int8)
    z = np.zeros(n_qubits, dtype=np.int8)
    n_y = 0
    for ch, w in zip(pauli_string, wires):
        xb, zb = _LABEL_TO_XZ[ch]
        x[w] = xb
        z[w] = zb
        if ch == "Y":
            n_y += 1
    # Each Y contributes a factor i (Y = i X Z), accumulated into phase.
    return cls(x, z, n_y % 4)

identity(n_qubits) classmethod #

Return the identity Pauli word on n_qubits.

Source code in qml_essentials/operations.py
@classmethod
def identity(cls, n_qubits: int) -> "PauliWord":
    """Return the identity Pauli word on *n_qubits*."""
    z = np.zeros(n_qubits, dtype=np.int8)
    return cls(z.copy(), z, 0)

leading_phase() #

Return the scalar c such that P = c * (bare Pauli string).

Because the bare string already contains i^{n_Y} from its Y factors, c = i^{phase - n_Y}.

Source code in qml_essentials/operations.py
def leading_phase(self) -> complex:
    r"""Return the scalar ``c`` such that ``P = c * (bare Pauli string)``.

    Because the bare string already contains ``i^{n_Y}`` from its Y factors,
    ``c = i^{phase - n_Y}``.
    """
    n_y = int(((self.x == 1) & (self.z == 1)).sum())
    return complex(1j ** ((self.phase - n_y) % 4))

to_list_repr() #

Return the legacy int list representation (I=-1, X=0, Y=1, Z=2).

Source code in qml_essentials/operations.py
def to_list_repr(self) -> np.ndarray:
    """Return the legacy int list representation (I=-1, X=0, Y=1, Z=2)."""
    out = np.full(self.n_qubits, -1, dtype=int)
    for q in range(self.n_qubits):
        label = _XZ_TO_LABEL[(int(self.x[q]), int(self.z[q]))]
        out[q] = {"I": -1, "X": 0, "Y": 1, "Z": 2}[label]
    return out

to_matrix() #

Return the dense operator matrix i^{phase} \bigotimes_q X^{x_q} Z^{z_q}.

The per-qubit factor is the symplectic product X^{x} Z^{z} (so the (1, 1) factor is XZ = -iY; the Y-vs-XZ phase is carried by i^{phase}). Inverse of :meth:from_matrix.

Source code in qml_essentials/operations.py
def to_matrix(self) -> jnp.ndarray:
    r"""Return the dense operator matrix ``i^{phase} \bigotimes_q X^{x_q} Z^{z_q}``.

    The per-qubit factor is the symplectic product ``X^{x} Z^{z}`` (so the
    ``(1, 1)`` factor is ``XZ = -iY``; the ``Y``-vs-``XZ`` phase is carried by
    ``i^{phase}``).  Inverse of :meth:`from_matrix`.
    """
    ident = _PAULI_MATRICES["I"]
    xmat = _PAULI_MATRICES["X"]
    zmat = _PAULI_MATRICES["Z"]
    mat = jnp.array([[1.0 + 0.0j]], dtype=_cdtype())
    for q in range(self.n_qubits):
        factor = (xmat if self.x[q] else ident) @ (zmat if self.z[q] else ident)
        mat = jnp.kron(mat, factor)
    return (1j**self.phase) * mat

to_pauli_string() #

Return the bare Pauli string (ignoring the global phase).

Source code in qml_essentials/operations.py
def to_pauli_string(self) -> str:
    """Return the bare Pauli string (ignoring the global phase)."""
    return "".join(
        _XZ_TO_LABEL[(int(self.x[q]), int(self.z[q]))] for q in range(self.n_qubits)
    )

to_pauli_string_and_phase() #

Return (bare Pauli string, leading scalar phase).

Source code in qml_essentials/operations.py
def to_pauli_string_and_phase(self) -> Tuple[str, complex]:
    """Return ``(bare Pauli string, leading scalar phase)``."""
    return self.to_pauli_string(), self.leading_phase()

zero_expectation() #

Return <0|P|0> for the all-zero computational basis state.

Non-zero only for diagonal words (I/Z only), in which case it equals the global phase i^{phase}.

Source code in qml_essentials/operations.py
def zero_expectation(self) -> complex:
    r"""Return ``<0|P|0>`` for the all-zero computational basis state.

    Non-zero only for diagonal words (I/Z only), in which case it equals the
    global phase ``i^{phase}``.
    """
    if not self.is_diagonal:
        return 0.0 + 0.0j
    return complex(1j**self.phase)

Pauli Circuit#

from qml_essentials.pauli import PauliCircuit

Wrapper for Pauli-Clifford Circuits described by Nemkov et al. (https://doi.org/10.1103/PhysRevA.108.032406). The code is inspired by the corresponding implementation: https://github.com/idnm/FourierVQA.

A Pauli Circuit only consists of parameterised Pauli-rotations and Clifford gates, which is the default for the most common VQCs.

Source code in qml_essentials/pauli.py
class PauliCircuit:
    """
    Wrapper for Pauli-Clifford Circuits described by Nemkov et al.
    (https://doi.org/10.1103/PhysRevA.108.032406). The code is inspired
    by the corresponding implementation: https://github.com/idnm/FourierVQA.

    A Pauli Circuit only consists of parameterised Pauli-rotations and Clifford
    gates, which is the default for the most common VQCs.
    """

    PAULI_ROTATION_GATES = (
        RX,
        RY,
        RZ,
        PauliRot,
    )

    SKIPPABLE_OPERATIONS = (Barrier,)

    @staticmethod
    def from_parameterised_circuit(
        tape: List[Operation],
        observables: Optional[List[Operation]] = None,
        n_qubits: Optional[int] = None,
    ) -> Tuple[List[Operation], List[Operation]]:
        """
        Transforms a list of operations into a Pauli-Clifford circuit.

        Args:
            tape: List of operations recorded from the circuit.
            observables: List of observable operations.  If ``None``, defaults
                to an empty list.
            n_qubits: Total number of qubits.  Inferred from the maximum wire
                index if ``None``.

        Returns:
            Tuple[List[Operation], List[Operation]]:
                The Pauli rotations of the canonical circuit and the
                (Clifford-evolved) observables.
        """
        if observables is None:
            observables = []

        operations = PauliCircuit.get_clifford_pauli_gates(tape)

        if n_qubits is None:
            n_qubits = PauliCircuit._infer_n_qubits(operations, observables)

        pauli_gates, final_cliffords = PauliCircuit.commute_all_cliffords_to_the_end(
            operations, n_qubits
        )

        observables = PauliCircuit.cliffords_in_observable(
            final_cliffords, observables, n_qubits
        )

        return pauli_gates, observables

    @staticmethod
    def get_parameters(operations: List[Operation]) -> list:
        """Flatten the parameter values of a tape (list of operations)."""
        return [p for op in operations for p in op.parameters]

    @staticmethod
    def _infer_n_qubits(
        operations: List[Operation], observables: List[Operation]
    ) -> int:
        """Infer the register size from the maximum wire index used."""
        max_wire = -1
        for op in list(operations) + list(observables):
            if op.wires:
                max_wire = max(max_wire, max(op.wires))
        return max_wire + 1

    @staticmethod
    def commute_all_cliffords_to_the_end(
        operations: List[Operation],
        n_qubits: int,
    ) -> Tuple[List[Operation], List[Operation]]:
        """
        This function moves all clifford gates to the end of the circuit,
        accounting for commutation rules.

        Args:
            operations (List[Operation]): The operations in the tape of the
                circuit
            n_qubits (int): Total number of qubits.

        Returns:
            Tuple[List[Operation], List[Operation]]:
                - List of the resulting Pauli-rotations
                - List of the resulting Clifford gates
        """
        first_clifford = -1
        for i in range(len(operations) - 2, -1, -1):
            j = i
            while (
                j + 1 < len(operations)  # Clifford has not alredy reached the end
                and PauliCircuit._is_clifford(operations[j])
                and PauliCircuit._is_pauli_rotation(operations[j + 1])
            ):
                pauli, clifford = PauliCircuit._evolve_clifford_rotation(
                    operations[j], operations[j + 1], n_qubits
                )
                operations[j] = pauli
                operations[j + 1] = clifford
                j += 1
                first_clifford = j

        # No Clifford gates are in the circuit
        if not PauliCircuit._is_clifford(operations[-1]):
            return operations, []

        pauli_rotations = operations[:first_clifford]
        clifford_gates = operations[first_clifford:]

        return pauli_rotations, clifford_gates

    @staticmethod
    def get_clifford_pauli_gates(tape: List[Operation]) -> List[Operation]:
        """
        This function decomposes all gates in the circuit to clifford and
        pauli-rotation gates.

        Args:
            tape: List of operations recorded from the circuit.

        Returns:
            List[Operation]: A list of operations consisting only of clifford
                and Pauli-rotation gates.
        """
        operations = []
        for operation in tape:
            if PauliCircuit._is_clifford(operation) or PauliCircuit._is_pauli_rotation(
                operation
            ):
                operations.append(operation)
            elif PauliCircuit._is_skippable(operation):
                continue
            else:
                # Composite gates (Rot, CRX/CRY/CRZ, ...) expose their own
                # Clifford + Pauli-rotation decomposition.
                try:
                    operations.extend(operation.decompose())
                except NotImplementedError:
                    raise NotImplementedError(
                        f"Gate {operation.name} cannot be decomposed into "
                        "Pauli rotations and Clifford gates. Consider using a "
                        "circuit ansatz that only uses RX, RY, RZ, PauliRot, "
                        "Rot, and standard Clifford gates."
                    )

        return operations

    @staticmethod
    def _is_skippable(operation: Operation) -> bool:
        """Whether an operation can be ignored (currently only barriers)."""
        return isinstance(operation, PauliCircuit.SKIPPABLE_OPERATIONS)

    @staticmethod
    def _is_clifford(operation: Operation) -> bool:
        """Whether an operation is a Clifford gate (reads ``Operation.is_clifford``).

        Clifford gates are commuted to the end via symbolic conjugation
        (:meth:`PauliWord.conjugate_by_clifford`); see ``Operation.is_clifford``.
        """
        return getattr(operation, "is_clifford", False)

    @staticmethod
    def _is_pauli_rotation(operation: Operation) -> bool:
        """Whether an operation is a Pauli rotation gate."""
        return isinstance(operation, PauliCircuit.PAULI_ROTATION_GATES)

    @staticmethod
    def _evolve_clifford_rotation(
        clifford: Operation, pauli: Operation, n_qubits: int
    ) -> Tuple[Operation, Operation]:
        """
        Compute the resulting operations when switching a Clifford gate and a
        Pauli rotation in the circuit, i.e. move the Clifford past the rotation:

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

        The evolved Pauli rotation is obtained by **symbolic** Clifford
        conjugation of the rotation generator (no matrices).

        Args:
            clifford (Operation): Clifford gate to move.
            pauli (Operation): Pauli rotation gate to move the clifford past.
            n_qubits (int): Total number of qubits.

        Returns:
            Tuple[Operation, Operation]:
                - Evolved Pauli rotation operator
                - The (unchanged) Clifford operator
        """
        if not any(p_c in clifford.wires for p_c in pauli.wires):
            return pauli, clifford

        param = pauli.parameters[0]

        gen_word = PauliWord.from_operation(pauli, n_qubits)
        evolved = gen_word.conjugate_by_clifford(clifford, adjoint_left=False)
        bare, phase = evolved.to_pauli_string_and_phase()

        # Clifford conjugation of a (Hermitian) Pauli generator yields +-1.
        param_factor = float(np.real(phase))

        pauli_str, qubits = PauliCircuit._remove_identities_from_paulistr(
            bare, list(range(n_qubits))
        )
        new_pauli = PauliRot(param * param_factor, pauli_str, qubits)

        return new_pauli, clifford

    @staticmethod
    def _remove_identities_from_paulistr(
        pauli_str: str, qubits: List[int]
    ) -> Tuple[str, List[int]]:
        """
        Removes identities from Pauli string and its corresponding qubits.

        Args:
            pauli_str (str): Pauli string
            qubits (List[int]): Corresponding qubit indices.

        Returns:
            Tuple[str, List[int]]:
                - Pauli string without identities
                - Qubits indices without the identities
        """

        reduced_qubits = []
        reduced_pauli_str = ""
        for i, p in enumerate(pauli_str):
            if p != "I":
                reduced_pauli_str += p
                reduced_qubits.append(qubits[i])

        return reduced_pauli_str, reduced_qubits

    @staticmethod
    def cliffords_in_observable(
        operations: List[Operation],
        original_obs: List[Operation],
        n_qubits: int,
    ) -> List[Operation]:
        """
        Integrates Clifford gates into the observables of the original ansatz,
        by symbolically conjugating each observable through the final Clifford
        sequence (``O -> C^dagger O C`` for each Clifford, applied in reverse).

        Args:
            operations (List[Operation]): Clifford gates
            original_obs (List[Operation]): Original observables from the
                circuit
            n_qubits (int): Total number of qubits.

        Returns:
            List[Operation]: Observables with Clifford operations absorbed.
                Each carries a cached symbolic ``_pauli_word`` for the
                Fourier-tree algorithm and a matrix for simulation.
        """
        observables = []
        for ob in original_obs:
            word = PauliWord.from_operation(ob, n_qubits)
            for clifford in operations[::-1]:
                word = word.conjugate_by_clifford(clifford, adjoint_left=True)
            observables.append(PauliCircuit._pauli_operation_from_word(word))
        return observables

    @staticmethod
    def _pauli_operation_from_word(word: PauliWord) -> Operation:
        """Build an observable :class:`Operation` from a symbolic Pauli word.

        The returned operation carries both a dense ``matrix`` (for the
        statevector simulator) and a cached ``_pauli_word`` / ``_pauli_label``
        (for symbolic consumers such as the Fourier tree).
        """
        bare, phase = word.to_pauli_string_and_phase()
        reduced_str, reduced_wires = PauliCircuit._remove_identities_from_paulistr(
            bare, list(range(word.n_qubits))
        )

        if not reduced_str:
            obs = Hermitian(
                matrix=phase * jnp.eye(2, dtype=_cdtype()), wires=[0], record=False
            )
            obs._pauli_label = "I"
        else:
            # Reuse the canonical Pauli matrix construction (bare string, then
            # multiply by the leading +-1/+-i phase).
            reduced_word = PauliWord.from_pauli_string(
                reduced_str, list(range(len(reduced_str))), len(reduced_str)
            )
            obs = Hermitian(
                matrix=phase * reduced_word.to_matrix(),
                wires=reduced_wires,
                record=False,
            )
            obs._pauli_label = reduced_str

        obs._pauli_word = word
        return obs

cliffords_in_observable(operations, original_obs, n_qubits) staticmethod #

Integrates Clifford gates into the observables of the original ansatz, by symbolically conjugating each observable through the final Clifford sequence (O -> C^dagger O C for each Clifford, applied in reverse).

Parameters:

Name Type Description Default
operations List[Operation]

Clifford gates

required
original_obs List[Operation]

Original observables from the circuit

required
n_qubits int

Total number of qubits.

required

Returns:

Type Description
List[Operation]

List[Operation]: Observables with Clifford operations absorbed. Each carries a cached symbolic _pauli_word for the Fourier-tree algorithm and a matrix for simulation.

Source code in qml_essentials/pauli.py
@staticmethod
def cliffords_in_observable(
    operations: List[Operation],
    original_obs: List[Operation],
    n_qubits: int,
) -> List[Operation]:
    """
    Integrates Clifford gates into the observables of the original ansatz,
    by symbolically conjugating each observable through the final Clifford
    sequence (``O -> C^dagger O C`` for each Clifford, applied in reverse).

    Args:
        operations (List[Operation]): Clifford gates
        original_obs (List[Operation]): Original observables from the
            circuit
        n_qubits (int): Total number of qubits.

    Returns:
        List[Operation]: Observables with Clifford operations absorbed.
            Each carries a cached symbolic ``_pauli_word`` for the
            Fourier-tree algorithm and a matrix for simulation.
    """
    observables = []
    for ob in original_obs:
        word = PauliWord.from_operation(ob, n_qubits)
        for clifford in operations[::-1]:
            word = word.conjugate_by_clifford(clifford, adjoint_left=True)
        observables.append(PauliCircuit._pauli_operation_from_word(word))
    return observables

commute_all_cliffords_to_the_end(operations, n_qubits) staticmethod #

This function moves all clifford gates to the end of the circuit, accounting for commutation rules.

Parameters:

Name Type Description Default
operations List[Operation]

The operations in the tape of the circuit

required
n_qubits int

Total number of qubits.

required

Returns:

Type Description
Tuple[List[Operation], List[Operation]]

Tuple[List[Operation], List[Operation]]: - List of the resulting Pauli-rotations - List of the resulting Clifford gates

Source code in qml_essentials/pauli.py
@staticmethod
def commute_all_cliffords_to_the_end(
    operations: List[Operation],
    n_qubits: int,
) -> Tuple[List[Operation], List[Operation]]:
    """
    This function moves all clifford gates to the end of the circuit,
    accounting for commutation rules.

    Args:
        operations (List[Operation]): The operations in the tape of the
            circuit
        n_qubits (int): Total number of qubits.

    Returns:
        Tuple[List[Operation], List[Operation]]:
            - List of the resulting Pauli-rotations
            - List of the resulting Clifford gates
    """
    first_clifford = -1
    for i in range(len(operations) - 2, -1, -1):
        j = i
        while (
            j + 1 < len(operations)  # Clifford has not alredy reached the end
            and PauliCircuit._is_clifford(operations[j])
            and PauliCircuit._is_pauli_rotation(operations[j + 1])
        ):
            pauli, clifford = PauliCircuit._evolve_clifford_rotation(
                operations[j], operations[j + 1], n_qubits
            )
            operations[j] = pauli
            operations[j + 1] = clifford
            j += 1
            first_clifford = j

    # No Clifford gates are in the circuit
    if not PauliCircuit._is_clifford(operations[-1]):
        return operations, []

    pauli_rotations = operations[:first_clifford]
    clifford_gates = operations[first_clifford:]

    return pauli_rotations, clifford_gates

from_parameterised_circuit(tape, observables=None, n_qubits=None) staticmethod #

Transforms a list of operations into a Pauli-Clifford circuit.

Parameters:

Name Type Description Default
tape List[Operation]

List of operations recorded from the circuit.

required
observables Optional[List[Operation]]

List of observable operations. If None, defaults to an empty list.

None
n_qubits Optional[int]

Total number of qubits. Inferred from the maximum wire index if None.

None

Returns:

Type Description
Tuple[List[Operation], List[Operation]]

Tuple[List[Operation], List[Operation]]: The Pauli rotations of the canonical circuit and the (Clifford-evolved) observables.

Source code in qml_essentials/pauli.py
@staticmethod
def from_parameterised_circuit(
    tape: List[Operation],
    observables: Optional[List[Operation]] = None,
    n_qubits: Optional[int] = None,
) -> Tuple[List[Operation], List[Operation]]:
    """
    Transforms a list of operations into a Pauli-Clifford circuit.

    Args:
        tape: List of operations recorded from the circuit.
        observables: List of observable operations.  If ``None``, defaults
            to an empty list.
        n_qubits: Total number of qubits.  Inferred from the maximum wire
            index if ``None``.

    Returns:
        Tuple[List[Operation], List[Operation]]:
            The Pauli rotations of the canonical circuit and the
            (Clifford-evolved) observables.
    """
    if observables is None:
        observables = []

    operations = PauliCircuit.get_clifford_pauli_gates(tape)

    if n_qubits is None:
        n_qubits = PauliCircuit._infer_n_qubits(operations, observables)

    pauli_gates, final_cliffords = PauliCircuit.commute_all_cliffords_to_the_end(
        operations, n_qubits
    )

    observables = PauliCircuit.cliffords_in_observable(
        final_cliffords, observables, n_qubits
    )

    return pauli_gates, observables

get_clifford_pauli_gates(tape) staticmethod #

This function decomposes all gates in the circuit to clifford and pauli-rotation gates.

Parameters:

Name Type Description Default
tape List[Operation]

List of operations recorded from the circuit.

required

Returns:

Type Description
List[Operation]

List[Operation]: A list of operations consisting only of clifford and Pauli-rotation gates.

Source code in qml_essentials/pauli.py
@staticmethod
def get_clifford_pauli_gates(tape: List[Operation]) -> List[Operation]:
    """
    This function decomposes all gates in the circuit to clifford and
    pauli-rotation gates.

    Args:
        tape: List of operations recorded from the circuit.

    Returns:
        List[Operation]: A list of operations consisting only of clifford
            and Pauli-rotation gates.
    """
    operations = []
    for operation in tape:
        if PauliCircuit._is_clifford(operation) or PauliCircuit._is_pauli_rotation(
            operation
        ):
            operations.append(operation)
        elif PauliCircuit._is_skippable(operation):
            continue
        else:
            # Composite gates (Rot, CRX/CRY/CRZ, ...) expose their own
            # Clifford + Pauli-rotation decomposition.
            try:
                operations.extend(operation.decompose())
            except NotImplementedError:
                raise NotImplementedError(
                    f"Gate {operation.name} cannot be decomposed into "
                    "Pauli rotations and Clifford gates. Consider using a "
                    "circuit ansatz that only uses RX, RY, RZ, PauliRot, "
                    "Rot, and standard Clifford gates."
                )

    return operations

get_parameters(operations) staticmethod #

Flatten the parameter values of a tape (list of operations).

Source code in qml_essentials/pauli.py
@staticmethod
def get_parameters(operations: List[Operation]) -> list:
    """Flatten the parameter values of a tape (list of operations)."""
    return [p for op in operations for p in op.parameters]

Math#

from qml_essentials.math import quantum_fisher_information, fubini_study_metric, fidelity, trace_distance, phase_difference

Compute the Quantum Fisher Information (QFI) at a parameter point.

The QFI is the metric tensor of the state manifold evaluated at params. It therefore requires the state as a function of the parameters rather than a single state; the Jacobian is obtained with forward-mode automatic differentiation (:func:jax.jacfwd), which yields the complex Jacobian directly for real-valued parameters.

Both pure and mixed states are supported and dispatched on the kind of object returned by state_fn (state vector vs. density matrix), mirroring :func:fidelity:

  • state vector of shape (d,) -> Fubini-Study formula (see :func:_qfi_statevector),
  • density matrix of shape (d, d) -> symmetric logarithmic derivative formula (see :func:_qfi_density).

The returned matrix has shape (P, P) where P is the total number of parameters (the parameter axes of params are flattened).

Parameters:

Name Type Description Default
state_fn

Callable mapping params to a normalised quantum state. Typically lambda p: model(params=p, inputs=x) with the model's execution_type set to "state" (pure) or "density" (mixed).

required
params ndarray

Parameters at which the QFI is evaluated. Must be passed in the shape expected by state_fn (e.g. the model's batched model.params).

required

Returns:

Type Description
ndarray

Real, symmetric QFI matrix of shape (P, P).

Raises:

Type Description
ValueError

If state_fn returns neither a state vector nor a square density matrix.

Source code in qml_essentials/math.py
def quantum_fisher_information(
    state_fn,
    params: jnp.ndarray,
) -> jnp.ndarray:
    r"""Compute the Quantum Fisher Information (QFI) at a parameter point.

    The QFI is the metric tensor of the state manifold evaluated at
    ``params``. It therefore requires the state as a *function* of the
    parameters rather than a single state; the Jacobian is obtained with
    forward-mode automatic differentiation (:func:`jax.jacfwd`), which yields
    the complex Jacobian directly for real-valued parameters.

    Both pure and mixed states are supported and dispatched on the kind of
    object returned by *state_fn* (state vector vs. density matrix), mirroring
    :func:`fidelity`:

    - state vector of shape ``(d,)`` -> Fubini-Study formula
      (see :func:`_qfi_statevector`),
    - density matrix of shape ``(d, d)`` -> symmetric logarithmic derivative
      formula (see :func:`_qfi_density`).

    The returned matrix has shape ``(P, P)`` where ``P`` is the total number of
    parameters (the parameter axes of *params* are flattened).

    Args:
        state_fn: Callable mapping *params* to a normalised quantum state.
            Typically ``lambda p: model(params=p, inputs=x)`` with the model's
            ``execution_type`` set to ``"state"`` (pure) or ``"density"``
            (mixed).
        params: Parameters at which the QFI is evaluated. Must be passed in the
            shape expected by *state_fn* (e.g. the model's batched
            ``model.params``).

    Returns:
        Real, symmetric QFI matrix of shape ``(P, P)``.

    Raises:
        ValueError: If *state_fn* returns neither a state vector nor a square
            density matrix.
    """
    state, jac = _state_and_jacobian(state_fn, params)

    if state.ndim == 1:
        jac = jac.reshape(state.shape[0], -1)
        return _qfi_statevector(jac, state)
    elif state.ndim == 2 and state.shape[-1] == state.shape[-2]:
        jac = jac.reshape(state.shape[0], state.shape[1], -1)
        return _qfi_density(jac, state)
    else:
        raise ValueError(
            "state_fn must return a state vector of shape (d,) or a density "
            f"matrix of shape (d, d), got shape {state.shape}."
        )

Compute the Fubini-Study metric tensor at a parameter point.

The Fubini-Study metric is the real part of the quantum geometric tensor on the manifold of pure states and equals the pure-state quantum Fisher information up to a factor of four, :math:F_{ij} = 4\,g_{ij}:

.. math::

g_{ij} = \mathrm{Re}\left[
    \braket{\partial_i\psi | \partial_j\psi}
    - \braket{\partial_i\psi | \psi}\braket{\psi | \partial_j\psi}
\right]

It is only defined for pure states; state_fn must therefore return a normalised state vector. See :func:quantum_fisher_information for the calling convention.

Parameters:

Name Type Description Default
state_fn

Callable mapping params to a normalised state vector. Typically lambda p: model(params=p, inputs=x) with the model's execution_type set to "state".

required
params ndarray

Parameters at which the metric is evaluated.

required

Returns:

Type Description
ndarray

Real, symmetric metric of shape (P, P) where P is the total

ndarray

number of parameters.

Raises:

Type Description
ValueError

If state_fn does not return a state vector.

Source code in qml_essentials/math.py
def fubini_study_metric(
    state_fn,
    params: jnp.ndarray,
) -> jnp.ndarray:
    r"""Compute the Fubini-Study metric tensor at a parameter point.

    The Fubini-Study metric is the real part of the quantum geometric tensor on
    the manifold of pure states and equals the pure-state quantum Fisher
    information up to a factor of four, :math:`F_{ij} = 4\,g_{ij}`:

    .. math::

        g_{ij} = \mathrm{Re}\left[
            \braket{\partial_i\psi | \partial_j\psi}
            - \braket{\partial_i\psi | \psi}\braket{\psi | \partial_j\psi}
        \right]

    It is only defined for pure states; *state_fn* must therefore return a
    normalised state vector. See :func:`quantum_fisher_information` for the
    calling convention.

    Args:
        state_fn: Callable mapping *params* to a normalised state vector.
            Typically ``lambda p: model(params=p, inputs=x)`` with the model's
            ``execution_type`` set to ``"state"``.
        params: Parameters at which the metric is evaluated.

    Returns:
        Real, symmetric metric of shape ``(P, P)`` where ``P`` is the total
        number of parameters.

    Raises:
        ValueError: If *state_fn* does not return a state vector.
    """
    state, jac = _state_and_jacobian(state_fn, params)

    if state.ndim != 1:
        raise ValueError(
            "The Fubini-Study metric is only defined for pure states; "
            f"state_fn must return a state vector of shape (d,), got shape "
            f"{state.shape}."
        )

    jac = jac.reshape(state.shape[0], -1)
    return _fubini_study_statevector(jac, state)

Compute the fidelity between two quantum states.

Accepts either state vectors or density matrices.

Parameters:

Name Type Description Default
state0 ndarray

State vector or density matrix.

required
state1 ndarray

State vector or density matrix (same kind as state0).

required

Returns:

Type Description
ndarray

Fidelity (scalar or shape (B,)).

Raises:

Type Description
ValueError

If the two states have incompatible shapes or different representations (vector vs. matrix).

Source code in qml_essentials/math.py
def fidelity(
    state0: jnp.ndarray,
    state1: jnp.ndarray,
) -> jnp.ndarray:
    r"""Compute the fidelity between two quantum states.

    Accepts either state vectors or density matrices.

    Args:
        state0: State vector or density matrix.
        state1: State vector or density matrix (same kind as *state0*).

    Returns:
        Fidelity (scalar or shape ``(B,)``).

    Raises:
        ValueError: If the two states have incompatible shapes or
            different representations (vector vs. matrix).
    """
    state0 = jnp.asarray(state0, dtype=_cdtype())
    state1 = jnp.asarray(state1, dtype=_cdtype())

    if state0.shape[-1] != state1.shape[-1]:
        raise ValueError("The two states must have the same number of wires.")

    is_sv0 = state0.ndim <= 2 and (
        state0.ndim == 1 or state0.shape[-2] != state0.shape[-1]
    )
    is_sv1 = state1.ndim <= 2 and (
        state1.ndim == 1 or state1.shape[-2] != state1.shape[-1]
    )

    if is_sv0 != is_sv1:
        raise ValueError(
            "Both states must be of the same kind "
            "(both state vectors or both density matrices)."
        )

    if is_sv0:
        return _fidelity_statevector(state0, state1)
    return _fidelity_dm(state0, state1)

Compute the trace distance between two quantum states.

Supports single density matrices of shape (2**N, 2**N) and batched density matrices of shape (B, 2**N, 2**N).

Parameters:

Name Type Description Default
state0 ndarray

Density matrix of shape (2**N, 2**N) or (B, 2**N, 2**N).

required
state1 ndarray

Density matrix of shape (2**N, 2**N) or (B, 2**N, 2**N).

required

Returns:

Type Description
ndarray

Trace distance (scalar or shape (B,)).

Source code in qml_essentials/math.py
def trace_distance(
    state0: jnp.ndarray,
    state1: jnp.ndarray,
) -> jnp.ndarray:
    r"""Compute the trace distance between two quantum states.

    Supports single density matrices of shape ``(2**N, 2**N)`` and batched
    density matrices of shape ``(B, 2**N, 2**N)``.

    Args:
        state0: Density matrix of shape ``(2**N, 2**N)`` or ``(B, 2**N, 2**N)``.
        state1: Density matrix of shape ``(2**N, 2**N)`` or ``(B, 2**N, 2**N)``.

    Returns:
        Trace distance (scalar or shape ``(B,)``).
    """
    state0 = jnp.asarray(state0, dtype=_cdtype())
    state1 = jnp.asarray(state1, dtype=_cdtype())

    if state0.shape[-1] != state1.shape[-1]:
        raise ValueError("The two states must have the same number of wires.")

    eigvals = jnp.abs(jnp.linalg.eigvalsh(state0 - state1))
    return jnp.sum(eigvals, axis=-1) / 2

Compute the phase difference between two state vectors.

A value of zero indicates the two states are related by at most a real global factor (i.e. no relative phase). The result lies in :math:[-\pi, 1 + \pi].

Supports single state vectors of shape (2**N,) and batched state vectors of shape (B, 2**N).

Parameters:

Name Type Description Default
state0 ndarray

State vector of shape (2**N,) or (B, 2**N).

required
state1 ndarray

State vector of shape (2**N,) or (B, 2**N).

required

Returns:

Type Description
ndarray

Phase difference (scalar or shape (B,)).

Source code in qml_essentials/math.py
def phase_difference(
    state0: jnp.ndarray,
    state1: jnp.ndarray,
) -> jnp.ndarray:
    r"""Compute the phase difference between two state vectors.

    A value of zero indicates the two states are related by at most a
    real global factor (i.e. no relative phase).  The result lies in
    :math:`[-\pi, 1 + \pi]`.

    Supports single state vectors of shape ``(2**N,)`` and batched state
    vectors of shape ``(B, 2**N)``.

    Args:
        state0: State vector of shape ``(2**N,)`` or ``(B, 2**N)``.
        state1: State vector of shape ``(2**N,)`` or ``(B, 2**N)``.

    Returns:
        Phase difference (scalar or shape ``(B,)``).
    """
    state0 = jnp.asarray(state0, dtype=_cdtype())
    state1 = jnp.asarray(state1, dtype=_cdtype())

    if state0.shape[-1] != state1.shape[-1]:
        raise ValueError("The two states must have the same number of wires.")

    batched0 = state0.ndim > 1
    batched1 = state1.ndim > 1

    idx0 = "ab" if batched0 else "b"
    idx1 = "ab" if batched1 else "b"
    target = "a" if (batched0 or batched1) else ""

    inner = jnp.einsum(f"{idx0},{idx1}->{target}", jnp.conj(state0), state1)
    return jnp.angle(inner)

Quantum Optimal Control#

from qml_essentials.qoc import QOC

Quantum Optimal Control for pulse-level gate synthesis.

Optimises pulse parameters to reproduce the unitary of standard quantum gates using a two-stage strategy.

Attributes:

Name Type Description
GATES_1Q List[str]

Names of supported single-qubit gates.

GATES_2Q List[str]

Names of supported two-qubit gates.

DEFAULT_PARAM_RANGES

Default parameter ranges for each gate.

Source code in qml_essentials/qoc.py
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
class QOC:
    """Quantum Optimal Control for pulse-level gate synthesis.

    Optimises pulse parameters to reproduce the unitary of standard
    quantum gates using a two-stage strategy.

    Attributes:
        GATES_1Q: Names of supported single-qubit gates.
        GATES_2Q: Names of supported two-qubit gates.
        DEFAULT_PARAM_RANGES: Default parameter ranges for each gate.
    """

    GATES_1Q: List[str] = ["RX", "RY", "RZ", "Rot", "H"]
    GATES_2Q: List[str] = ["CX", "CY", "CZ", "CRX", "CRY", "CRZ"]

    DEFAULT_PARAM_RANGES = {
        1: [(0.05, 3.0)],  # evolution time
        2: [(0.05, 3.0), (0.05, 3.0)],  # not typically used
        3: [(0.05, 3.0), (0.05, 3.0), (0.05, 3.0)],  # [A, sigma, t]
        4: [(0.05, 3.0), (0.05, 3.0), (0.05, 3.0), (0.05, 3.0)],  # [A, beta, sigma, t]
    }

    def __init__(
        self,
        envelope: str,
        cost_fns: List[Tuple[str, Union[float, Tuple[float, ...]]]],
        t_target: float,
        n_steps: int,
        n_samples: int,
        learning_rate: float,
        log_interval: int = 50,
        file_dir: str = None,
        warmup_ratio: float = 0.0,
        end_lr_ratio: float = 1.0,
        n_restarts: int = 1,
        restart_noise_scale: float = 0.5,
        grad_clip: float = 1.0,
        random_seed: int = 42,
        scan_steps: int = 0,
        scan_grid_size: int = 5,
        scan_ranges: Optional[List[Tuple[float, float]]] = None,
        log_scale_params: Optional[List[int]] = None,
        early_stop_patience: int = 0,
        early_stop_min_delta: float = 0.0,
        plot: bool = False,
    ):
        """
        Initialize Quantum Optimal Control with Pulse-level Gates.

        Args:
            envelope (str): Pulse envelope shape to use for optimization.
                Must be one of the registered envelopes in PulseEnvelope
                (e.g. 'gaussian', 'square', 'cosine', 'drag', 'sech').
            cost_fns (list): List of ``(name, weight)`` tuples that select
                which cost functions to use and their weights.  name must
                be a key in :class:`CostFnRegistry`.  *weight* is either a
                single float or a tuple of floats matching the number of
                return values of the cost function.
            t_target (float, optional): Target evolution time for the
                ``evolution_time`` cost function.  Required when
                ``"evolution_time"`` is among the selected cost functions.
            n_steps (int): Number of steps in optimization.
            n_samples (int): Number of parameter samples per step.
            learning_rate (float): Peak learning rate for AdamW. When a
                warmup/decay schedule is active this is the maximum LR
                reached after the warmup phase.
            log_interval (int): Interval for logging.
            file_dir (str): Directory to save results.
            warmup_ratio (float): Fraction of ``n_steps`` used for linear
                warmup (0.0 - 1.0).  Set to 0.0 to disable warmup and use
                a constant learning rate throughout.  A value of e.g. 0.05
                means the first 5 % of steps linearly ramp the LR from
                ``end_lr_ratio * learning_rate`` to ``learning_rate``.
            end_lr_ratio (float): The final learning rate is
                ``end_lr_ratio * learning_rate``.  Also used as the initial
                LR at the start of warmup.  Set to 0.0 for full cosine
                decay to zero; set to 1.0 (together with
                ``warmup_ratio=0.0``) to recover a constant LR.
            n_restarts (int): Number of random restarts for the
                optimisation.  The first run uses the initial parameters
                as-is; subsequent runs add scaled random perturbations.
                The best result across all restarts is kept.
                Set to 1 to disable restarts (default behaviour).
            restart_noise_scale (float): Standard deviation of the
                Gaussian noise added to the initial parameters for each
                restart (relative to the absolute value of each parameter).
                Defaults to 0.5 (50 % relative perturbation).  Note that
                the package-level default in ``default_qoc_params`` is a
                much smaller ``0.01`` because the QOC loss landscape is
                highly sensitive to initial conditions and large
                perturbations routinely move restarts into useless
                basins; tune up only if you have reason to believe the
                initial point is far from any good basin.
            grad_clip (float): Maximum global gradient norm.  Gradients
                are clipped to this value before being passed to the
                optimiser, which stabilises training when the loss
                landscape has steep regions.  Set to ``float('inf')`` or
                0.0 to disable.  Defaults to 1.0.
            random_seed (int): Base random seed for generating restart
                perturbations.  Defaults to 42.
            scan_steps (int): Number of short gradient-descent steps to
                run for each candidate in the coarse grid search
                (Stage 0).  Set to 0 to disable the grid scan entirely
                and rely solely on restarts.  A value of 20-50 is
                usually enough to identify promising basins.  Defaults
                to 0.
            scan_grid_size (int): Number of points per parameter
                dimension in the coarse grid.  The total number of
                candidates is ``scan_grid_size ** n_params``, so keep
                this small for high-dimensional parameter spaces.
                Defaults to 5.
            scan_ranges (Optional[List[Tuple[float, float]]]): Per-
                parameter ``(lo, hi)`` ranges for the grid scan.  If
                ``None``, heuristic ranges are used based on the
                envelope type: amplitude in ``[0.5, 30]``, width/sigma
                in ``[0.05, 2]``, and evolution time in ``[0.05, 2]``.
                Must have length equal to the number of pulse parameters
                if provided.
            log_scale_params (Optional[List[int]]): Indices of pulse
                parameters that should be optimised in log-space.  For
                these parameters the optimizer sees ``log(p)`` and the
                actual parameter used in the simulation is ``exp(log_p)``.
                This dramatically improves convergence when the optimal
                value may differ from the initial value by an order of
                magnitude (e.g. amplitude, evolution time).
                If ``None``, defaults to ``[0, -1]`` (amplitude and
                evolution time) for envelopes with ≥ 2 envelope params,
                or ``[]`` otherwise.
            early_stop_patience (int): Number of consecutive
                Stage-1 steps with no improvement greater than
                ``early_stop_min_delta`` after which optimisation
                exits early.  Set to ``0`` (default) to disable.
                Only honoured in the single-restart (sequential)
                path; when ``n_restarts > 1`` the parallel
                vmap+scan path always runs the full ``n_steps``.
            early_stop_min_delta (float): Minimum decrease in loss
                that counts as an improvement for the early-stopping
                patience counter.  Defaults to ``0.0`` (any strict
                improvement resets the counter).
            plot (bool): If ``True``, save a loss-landscape figure after
                Phase 0 and a loss-curve figure after Phase 1 to
                ``file_dir``.  Requires ``matplotlib`` to be installed.
                Defaults to ``False``.
        """
        self.envelope = envelope
        self.n_steps = n_steps
        self.n_samples = n_samples
        self.learning_rate = learning_rate
        self.warmup_ratio = warmup_ratio
        self.end_lr_ratio = end_lr_ratio
        self.log_interval = log_interval
        self.file_dir = (
            file_dir if file_dir else os.path.dirname(os.path.realpath(__file__))
        )
        self.t_target = t_target
        self.n_restarts = max(1, n_restarts)
        self.restart_noise_scale = restart_noise_scale
        self.grad_clip = grad_clip
        self.random_key = jax.random.PRNGKey(random_seed)
        self.scan_steps = scan_steps
        self.scan_grid_size = scan_grid_size
        self.scan_ranges = scan_ranges

        # Determine log-scale param indices
        envelope_info = PulseEnvelope.get(envelope)
        n_env = envelope_info["n_envelope_params"]
        if log_scale_params is not None:
            self.log_scale_params = log_scale_params
        elif n_env >= 2:
            # Default: amplitude (index 0) and evolution time (last)
            self.log_scale_params = [0, -1]
        else:
            self.log_scale_params = []

        # Mask cache used by ``_to_log_space``/``_from_log_space``;
        # rebuilt lazily because the mask length depends on the size of
        # the param vector being converted (per-gate vs joint).
        self._log_mask_cache: Dict[int, jnp.ndarray] = {}

        self.early_stop_patience = max(0, int(early_stop_patience))
        self.early_stop_min_delta = float(early_stop_min_delta)

        self.plot = plot

        log.info(
            f"Training parameters: {self.n_steps} steps, "
            f"{self.n_samples} samples, {self.learning_rate} learning rate"
        )
        log.info(
            f"LR schedule: warmup_ratio={self.warmup_ratio}, "
            f"end_lr_ratio={self.end_lr_ratio}"
        )

        log.info(f"Envelope: {self.envelope}")
        log.info(f"Target evolution time: {self.t_target}")
        log.info(
            f"Restarts: {self.n_restarts}, noise_scale={self.restart_noise_scale}, "
            f"grad_clip={self.grad_clip}"
        )
        if PulseInformation.get_rwa():
            log.info("Using RWA. Rotating frame is ignored.")
        else:
            log.info(f"Using no RWA and {PulseInformation.get_frame()} frame.")

        if self.early_stop_patience > 0:
            log.info(
                f"Early stopping: patience={self.early_stop_patience}, "
                f"min_delta={self.early_stop_min_delta:g}"
            )
        log.info(
            f"Grid scan: scan_steps={self.scan_steps}, "
            f"scan_grid_size={self.scan_grid_size}, "
            f"log_scale_params={self.log_scale_params}"
        )
        log.info(f"Using cost function(s) {cost_fns}")

        # Validate each entry against the registry
        summed_weights = 0
        for name, _weight in cost_fns:
            CostFnRegistry.get(name)  # raises ValueError if unknown
            summed_weights += sum(_weight) if isinstance(_weight, tuple) else _weight
        assert jnp.isclose(summed_weights, 1.0, rtol=1e-8), (
            f"Cost function weights must sum to 1. Got {summed_weights}"
        )

        self.cost_fns = cost_fns

        # Configure the pulse system with the selected envelope
        PulseInformation.set_envelope(self.envelope)

    def save_results(self, gate: str, fidelity: float, pulse_params) -> None:
        """Save optimised pulse parameters and fidelity for a gate to CSV.

        If the gate already exists in the file, its entry is overwritten
        regardless of whether the new fidelity is higher.  A warning is
        logged when the existing fidelity was better.

        Args:
            gate: Name of the gate (e.g. ``"RX"``).
            fidelity: Achieved fidelity of the optimised pulse.
            pulse_params (jnp.ndarray): Optimised pulse parameters for the gate.
        """
        if self.file_dir is not None:
            os.makedirs(self.file_dir, exist_ok=True)
            filename = os.path.join(self.file_dir, f"qoc_results_{self.envelope}.csv")

            reader = None
            if os.path.isfile(filename):
                with open(filename, mode="r", newline="") as f:
                    reader = csv.reader(f.readlines())

            entry = [gate] + [fidelity] + list(map(float, pulse_params))

            with open(filename, mode="w", newline="") as f:
                writer = csv.writer(f)
                match = False
                if reader is not None:
                    for row in reader:
                        # gate already exists
                        if row[0] == gate:
                            if fidelity <= float(row[1]):
                                log.warning(
                                    f"Pulse parameters for {gate} already exist with "
                                    f"higher fidelity ({row[1]} >= {fidelity})"
                                )
                            writer.writerow(entry)
                            match = True
                        # any other gate
                        else:
                            writer.writerow(row)
                # gate does not exist
                if not match:
                    writer.writerow(entry)

    def _log_mask(self, n: int) -> jnp.ndarray:
        """Return a boolean mask of length ``n`` marking log-scaled indices."""
        cached = self._log_mask_cache.get(n)
        if cached is not None and cached.shape[0] == n:
            return cached
        mask = np.zeros(n, dtype=bool)
        for idx in self.log_scale_params:
            i = idx if idx >= 0 else n + idx
            if 0 <= i < n:
                mask[i] = True
        out = jnp.asarray(mask)
        self._log_mask_cache[n] = out
        return out

    def _to_log_space(self, params: jnp.ndarray) -> jnp.ndarray:
        """Convert selected parameters to log-space for optimisation.

        Parameters at indices in ``self.log_scale_params`` are replaced
        by ``log(|p| + eps)`` so the optimiser operates on a
        logarithmic scale.  All other parameters are left unchanged.
        """
        if not self.log_scale_params:
            return params
        mask = self._log_mask(params.shape[0])
        log_vals = jnp.log(jnp.abs(params) + 1e-12)
        return jnp.where(mask, log_vals, params)

    def _from_log_space(self, log_params: jnp.ndarray) -> jnp.ndarray:
        """Convert selected parameters back from log-space.

        Inverse of :meth:`_to_log_space`.  Parameters at indices in
        ``self.log_scale_params`` are exponentiated; all others are
        passed through unchanged.
        """
        if not self.log_scale_params:
            return log_params
        mask = self._log_mask(log_params.shape[0])
        return jnp.where(mask, jnp.exp(log_params), log_params)

    # Multiplicative factors used to build a centred grid around the
    # supplied init parameters when no explicit ``scan_ranges`` are
    # given.  ``1.0`` is included so the init point itself is always a
    # candidate (Stage 0 cannot otherwise re-evaluate it as a grid
    # point — only as the baseline ``best_scan_loss``).
    SCAN_REL_FACTORS: Tuple[float, ...] = (0.5, 0.75, 1.0, 1.25, 1.5)

    def _build_scan_grid(
        self,
        n_params: int,
        init_pulse_params: Optional[jnp.ndarray] = None,
    ) -> Tuple[jnp.ndarray, List[jnp.ndarray]]:
        """Build a coarse parameter grid for the initial scan phase.

        If the user supplied ``scan_ranges`` they take precedence and
        a log-spaced grid is built within those bounds.  Otherwise, when
        ``init_pulse_params`` is available, a **multiplicative grid
        centred on the init point** is used (each axis spans
        ``init * SCAN_REL_FACTORS``) so that already-optimised init
        params are always re-evaluated and only their immediate
        neighbourhood is explored.  This avoids the failure mode where
        the global ``DEFAULT_PARAM_RANGES`` brackets exclude the actual
        optimum (the previous default range was ``(0.05, 3.0)`` per
        axis, which clipped DRAG amplitudes around 3.1 and made the
        scan systematically worse than the init point).

        Args:
            n_params: Number of pulse parameters.
            init_pulse_params: Optional init params used to centre the
                multiplicative grid when ``scan_ranges`` is ``None``.

        Returns:
            Tuple of:
            - Array of shape ``(n_candidates, n_params)`` with grid points.
            - List of 1-D arrays, one per parameter axis.
        """
        if self.scan_ranges is not None:
            ranges = self.scan_ranges
            assert len(ranges) == n_params, (
                f"scan_ranges has {len(ranges)} entries but gate has "
                f"{n_params} parameters."
            )
            # Build log-spaced grids for each parameter
            axes = []
            for lo, hi in ranges:
                axes.append(
                    jnp.logspace(jnp.log10(lo), jnp.log10(hi), self.scan_grid_size)
                )
        elif init_pulse_params is not None:
            # Multiplicative grid centred on init params.  We pick
            # ``scan_grid_size`` factors symmetric around 1.0.  When
            # ``scan_grid_size`` matches the static SCAN_REL_FACTORS
            # length we use those; otherwise build a symmetric linspace.
            if self.scan_grid_size == len(self.SCAN_REL_FACTORS):
                factors = jnp.array(self.SCAN_REL_FACTORS, dtype=jnp.float64)
            else:
                half = (self.scan_grid_size - 1) / 2.0
                if half <= 0:
                    factors = jnp.array([1.0], dtype=jnp.float64)
                else:
                    factors = jnp.linspace(
                        1.0 - 0.5,
                        1.0 + 0.5,
                        self.scan_grid_size,
                        dtype=jnp.float64,
                    )
            axes = [factors * float(p) for p in init_pulse_params]
        else:
            # Fall back to legacy log-spaced default ranges
            ranges = self.DEFAULT_PARAM_RANGES.get(
                n_params,
                [(0.1, 10.0)] * n_params,
            )
            axes = []
            for lo, hi in ranges:
                axes.append(
                    jnp.logspace(jnp.log10(lo), jnp.log10(hi), self.scan_grid_size)
                )

        # Cartesian product of all axes
        grid = jnp.array(list(itertools.product(*axes)))
        return grid, axes

    def stage_0_opt(
        self, init_pulse_params: jnp.ndarray, total_cost: Callable
    ) -> Tuple[jnp.ndarray, Optional[Tuple[List[jnp.ndarray], list]]]:
        """Run the coarse grid-scan phase (Stage 0).

        Evaluates a Cartesian grid of parameter candidates using the
        **full weighted cost** (fidelity + phase, plus any other
        registered terms) — the same objective as Stage 1.  Each
        candidate is refined with a few fast gradient steps.  Returns
        the best-found parameters.

        Sharing the objective with Stage 1 prevents the grid scan from
        landing in a basin that has high fidelity but a biased phase
        which Adam then has to migrate out of (the previous
        fidelity-only scan caused exactly this failure mode for RX/RY,
        whose phase residuals compounded in the CRX decomposition).

        Robustness: candidates that produce a non-finite loss (e.g. when
        the underlying pulse drives the integrator into a NaN — typical
        for very narrow DRAG envelopes) are skipped with a warning.  For
        the duration of the scan, :class:`qml_essentials.evolution.Evolution` is
        switched into ``throw=False`` mode so a single bad candidate
        cannot abort the loop with ``MaxStepsReached``; the previous
        defaults are restored on exit.

        Args:
            init_pulse_params: Initial pulse parameters to compare against.
            total_cost: Combined cost callable (same as Stage 1).

        Returns:
            Tuple of:
            - Best pulse parameters found during the scan.
            - ``(grid_axes, landscape_data)`` if the grid scan ran, else
              ``None``.  ``landscape_data`` is a list of
              ``(candidate_index, original_params, loss)`` tuples for
              every successful scan candidate.
        """

        def total_cost_log(log_params, *args):
            return total_cost(self._from_log_space(log_params), *args)

        best_scan_params = init_pulse_params
        best_scan_loss = _safe_eval(total_cost, init_pulse_params)
        if not jnp.isfinite(best_scan_loss):
            log.warning(
                "Stage 0: initial pulse parameters produced a non-finite "
                "loss; falling back to a placeholder loss of +inf."
            )

        landscape_data: list = []
        axes_out: Optional[List[jnp.ndarray]] = None

        if self.scan_steps > 0:
            log.info(
                f"Stage 0: Grid scan with {self.scan_grid_size}^"
                f"{len(init_pulse_params)} candidates, "
                f"{self.scan_steps} steps each"
            )

            grid, axes_out = self._build_scan_grid(
                len(init_pulse_params),
                init_pulse_params=init_pulse_params,
            )
            log.info(f"  Total candidates: {len(grid)}")

            # Use a fast Adam for the scan phase.  The aggressive 5×
            # multiplier originally used here tended to push refined
            # candidates *out* of good basins; 2× keeps the refinement
            # localised.  Always-evaluate-the-raw-candidate below
            # additionally guards against this.
            scan_optimizer = optax.chain(
                optax.clip_by_global_norm(
                    self.grad_clip if self.grad_clip > 0 else 1.0
                ),
                optax.adam(self.learning_rate * 2),
            )

            @jax.jit
            def refine_candidate(log_candidate):
                """Run ``self.scan_steps`` Adam steps on a single candidate.

                Fused into a single ``jax.lax.scan`` so the whole
                refinement is one XLA program — no per-step host
                syncs, no Python-loop dispatch.  Returns the final
                log-params and a scalar bool ``failed`` flag (set if
                any intermediate update produced a non-finite value).
                """

                opt_state0 = scan_optimizer.init(log_candidate)

                def body(carry, _):
                    log_p, opt_state, failed = carry
                    loss, grads = jax.value_and_grad(total_cost_log)(log_p)
                    updates, opt_state = scan_optimizer.update(grads, opt_state, log_p)
                    new_log_p = optax.apply_updates(log_p, updates)
                    new_failed = failed | (~jnp.all(jnp.isfinite(new_log_p)))
                    # Freeze on failure so subsequent steps cannot
                    # propagate NaNs further.
                    new_log_p = jnp.where(new_failed, log_p, new_log_p)
                    return (new_log_p, opt_state, new_failed), loss

                (final_log_p, _, failed), _ = jax.lax.scan(
                    body,
                    (log_candidate, opt_state0, jnp.bool_(False)),
                    None,
                    length=self.scan_steps,
                )
                return final_log_p, failed

            # Switch the underlying ODE solver to non-throwing mode for
            # the duration of the scan so candidates that exceed the step
            # budget produce NaN unitaries (and therefore +inf losses)
            # rather than aborting the whole grid loop.
            prev_solver_defaults = js.Evolution.set_solver_defaults(throw=False)
            n_skipped = 0
            n_raw_better = 0
            try:
                for ci, candidate in enumerate(grid):
                    log_candidate = self._to_log_space(candidate)

                    # Evaluate the raw (unrefined) candidate so an
                    # over-aggressive refinement step cannot discard
                    # an already-good grid point.
                    raw_loss = _safe_eval(total_cost, candidate)

                    try:
                        log_p, failed_flag = refine_candidate(log_candidate)
                    except Exception as exc:  # pragma: no cover - defensive
                        log.debug(
                            f"  Candidate {ci + 1}/{len(grid)} "
                            f"raised during refinement: {exc}; skipping."
                        )
                        physical_p = candidate
                        loss = raw_loss
                    else:
                        if bool(failed_flag):
                            physical_p = candidate
                            loss = raw_loss
                        else:
                            physical_p = self._from_log_space(log_p)
                            if not jnp.all(jnp.isfinite(physical_p)):
                                physical_p = candidate
                                loss = raw_loss
                            else:
                                loss = _safe_eval(total_cost, physical_p)

                    # Keep the better of (raw, refined) for this candidate.
                    if jnp.isfinite(raw_loss) and (
                        not jnp.isfinite(loss) or raw_loss < loss
                    ):
                        physical_p = candidate
                        loss = raw_loss
                        n_raw_better += 1

                    if not jnp.isfinite(loss):
                        n_skipped += 1
                        continue

                    landscape_data.append((ci, candidate, float(loss)))

                    if loss < best_scan_loss:
                        best_scan_loss = loss
                        best_scan_params = physical_p
                        log.info(
                            f"  Candidate {ci + 1}/{len(grid)}: "
                            f"loss={float(loss):.6e} improved with "
                            f"params={physical_p}"
                        )
            finally:
                # Always restore the previous solver defaults so other
                # callers (including Stage 1) are unaffected.
                if prev_solver_defaults:
                    js.Evolution.set_solver_defaults(**prev_solver_defaults)

            if n_skipped:
                log.warning(
                    f"Stage 0: skipped {n_skipped}/{len(grid)} candidates "
                    f"due to solver failure or non-finite loss "
                    f"(typical for very narrow / very large-amplitude "
                    f"DRAG pulses)."
                )
            if n_raw_better:
                log.info(
                    f"Stage 0: {n_raw_better}/{len(grid)} candidates "
                    f"were better unrefined than after the {self.scan_steps}-"
                    f"step refinement; raw values were kept."
                )

            log.info(
                f"Stage 0 complete. Best loss: "
                f"{float(best_scan_loss):.6e}, "
                f"params: {best_scan_params}"
            )

        scan_data = (axes_out, landscape_data) if self.scan_steps > 0 else None
        return best_scan_params, scan_data

    def stage_1_opt(
        self, best_scan_params: jnp.ndarray, total_costs: Callable
    ) -> Tuple[jnp.ndarray, list, jnp.ndarray]:
        """Run multi-restart gradient optimisation (Stage 1).

        Performs ``n_restarts`` independent AdamW runs with the full
        (weighted) cost function.  The first restart uses
        ``best_scan_params`` directly; subsequent restarts add random
        perturbations.  Parameters specified in ``log_scale_params`` are
        optimised in log-space.

        When ``n_restarts == 1`` we keep the original single-restart
        Python loop (it preserves per-step ``log.info`` granularity
        and avoids the vmap/scan compilation overhead).  When
        ``n_restarts > 1`` we ``vmap`` the optimiser over restarts and
        run the inner step loop with :func:`jax.lax.scan`, fusing all
        ``n_restarts × n_steps`` steps into a single XLA program.

        Args:
            best_scan_params: Starting parameters (typically from Stage 0).
            total_costs: Combined cost callable.

        Returns:
            Tuple of ``(best_params, loss_history, best_loss)`` from the
            best restart.
        """

        # Wrap the cost function with log-space reparameterisation
        def total_costs_log(log_params):
            return total_costs(self._from_log_space(log_params))

        # Build learning rate schedule
        warmup_steps = int(self.n_steps * self.warmup_ratio)
        end_value = self.learning_rate * self.end_lr_ratio

        if warmup_steps > 0 or self.end_lr_ratio < 1.0:
            schedule = optax.warmup_cosine_decay_schedule(
                init_value=(end_value if warmup_steps > 0 else self.learning_rate),
                peak_value=self.learning_rate,
                warmup_steps=warmup_steps,
                decay_steps=self.n_steps,
                end_value=end_value,
            )
        else:
            schedule = self.learning_rate

        optimizer = _build_optimizer(schedule, self.grad_clip)

        if self.n_restarts <= 1:
            return self._stage_1_sequential(
                best_scan_params, total_costs, total_costs_log, optimizer
            )
        return self._stage_1_parallel(
            best_scan_params, total_costs, total_costs_log, optimizer
        )

    def _perturb_starts(self, start_params: jnp.ndarray) -> jnp.ndarray:
        """Pre-build the ``(n_restarts, n_params)`` matrix of restart starts.

        Restart 0 is the unperturbed start; subsequent restarts add
        Gaussian noise scaled by ``max(|start|, 0.1) *
        restart_noise_scale``.  Indices that are optimised in
        log-space (plus the evolution time at index ``-1``) are kept
        positive via ``jnp.abs`` so the subsequent ``log`` is safe.
        """
        n_params = start_params.shape[0]
        keys = jax.random.split(self.random_key, self.n_restarts)
        # Shape (n_restarts, n_params); restart 0 is intentionally zero
        # noise so the unperturbed start is preserved.
        noise = jax.vmap(lambda k: jax.random.normal(k, shape=(n_params,)))(keys)
        noise = noise.at[0].set(0.0)
        scale = jnp.maximum(jnp.abs(start_params), 0.1) * self.restart_noise_scale
        starts = start_params[None, :] + noise * scale[None, :]

        # Keep the evolution time and any log-scaled indices positive.
        positive_mask = np.zeros(n_params, dtype=bool)
        positive_mask[-1] = True
        for idx in self.log_scale_params:
            i = idx if idx >= 0 else n_params + idx
            if 0 <= i < n_params:
                positive_mask[i] = True
        positive_mask_j = jnp.asarray(positive_mask)
        starts = jnp.where(positive_mask_j[None, :], jnp.abs(starts), starts)
        return starts

    def _stage_1_sequential(
        self,
        start_params: jnp.ndarray,
        total_costs: Callable,
        total_costs_log: Callable,
        optimizer,
    ) -> Tuple[jnp.ndarray, list, jnp.ndarray]:
        """Single-restart Stage 1, fused into a single ``jax.lax.scan``.

        The whole optimisation loop (n_steps × Adam updates) compiles
        to one XLA program, eliminating the per-step Python overhead
        and per-step host/device syncs that the previous Python ``for``
        loop incurred.  Early stopping is preserved via *masked
        updates*: once the patience condition trips, subsequent steps
        leave the parameters and loss unchanged.  Compute is not
        skipped (lax.scan has fixed length) but the optimiser state
        and parameter trajectory freeze, matching the previous
        early-stop semantics modulo wall-clock savings.
        """

        params = start_params
        log_params = self._to_log_space(params)
        opt_state = optimizer.init(log_params)

        init_loss = total_costs(params)
        min_delta = self.early_stop_min_delta
        patience = self.early_stop_patience
        # ``patience <= 0`` ⇒ early stopping disabled.  Use a large
        # constant so the masked-update path is never triggered.
        eff_patience = patience if patience > 0 else self.n_steps + 1

        def scan_body(carry, _):
            (
                log_params,
                opt_state,
                best_loss,
                best_log_params,
                steps_since_improve,
                stopped_flag,
                stopped_step,
                step_idx,
            ) = carry

            loss, grads = jax.value_and_grad(total_costs_log)(log_params)
            updates, new_opt_state = optimizer.update(grads, opt_state, log_params)
            stepped_log_params = optax.apply_updates(log_params, updates)

            # Improvement test (uses the pre-update loss, matching the
            # original semantics where the loss recorded on step *i*
            # corresponds to the params *before* that step's update).
            improved = loss < best_loss - min_delta
            best_loss = jnp.where(improved, loss, best_loss)
            # Save the params that *produced* the improving loss
            # (i.e. the pre-update ``log_params``).  ``improved`` is a
            # scalar bool and broadcasts against the 1-D params arrays.
            best_log_params = jnp.where(improved, log_params, best_log_params)
            steps_since_improve = jnp.where(
                improved, jnp.int32(0), steps_since_improve + jnp.int32(1)
            )

            # Latch the early-stop flag once it fires.
            trigger = steps_since_improve >= jnp.int32(eff_patience)
            new_stopped_flag = stopped_flag | trigger
            stopped_step = jnp.where(
                stopped_flag,
                stopped_step,
                jnp.where(trigger, step_idx + jnp.int32(1), stopped_step),
            )

            # Mask the update once stopped: freeze params/optimiser.
            new_log_params = jnp.where(new_stopped_flag, log_params, stepped_log_params)
            new_opt_state_kept = jax.tree_util.tree_map(
                lambda new, old: jnp.where(new_stopped_flag, old, new),
                new_opt_state,
                opt_state,
            )

            new_carry = (
                new_log_params,
                new_opt_state_kept,
                best_loss,
                best_log_params,
                steps_since_improve,
                new_stopped_flag,
                stopped_step,
                step_idx + jnp.int32(1),
            )
            return new_carry, loss

        init_carry = (
            log_params,  # log_params
            opt_state,  # opt_state
            init_loss,  # best_loss
            log_params,  # best_log_params
            jnp.int32(0),  # steps_since_improve
            jnp.bool_(False),  # stopped_flag
            jnp.int32(self.n_steps),  # stopped_step (default = n_steps)
            jnp.int32(0),  # step_idx
        )

        @jax.jit
        def run_scan(carry):
            return jax.lax.scan(scan_body, carry, None, length=self.n_steps)

        final_carry, step_losses = run_scan(init_carry)
        (
            _,
            _,
            best_loss,
            best_log_params,
            _,
            stopped_flag,
            stopped_step,
            _,
        ) = final_carry

        # One sync: pull just what we need for logging in a single
        # device->host transfer instead of a per-step ``.item()`` call.
        host_step_losses, host_best_loss, host_stopped, host_stopped_step = (
            jax.device_get((step_losses, best_loss, stopped_flag, stopped_step))
        )

        # Periodic progress log (replaces the per-step inline log;
        # cheap because step losses already live on the host).
        for step in range(0, self.n_steps, max(1, self.log_interval)):
            log.info(
                f"Step {step}/{self.n_steps}, Loss: {float(host_step_losses[step]):.3e}"
            )
        if bool(host_stopped):
            log.info(
                f"Early stop at step {int(host_stopped_step)}/{self.n_steps} "
                f"(no improvement > {min_delta:g} for "
                f"{self.early_stop_patience} steps)."
            )

        log.info(
            f"Restart 1/1 finished with best loss: {float(host_best_loss):.3e}"
            + (
                f" (early stopped at step {int(host_stopped_step)})"
                if bool(host_stopped)
                else ""
            )
        )

        # Reconstruct the historical loss list shape: leading entry is
        # the initial (pre-step-0) loss, followed by one entry per
        # scan step.  Match the previous return type (``list``) so
        # downstream plotting code is unchanged.
        loss_history = [init_loss] + list(step_losses)

        best_pulse_params = self._from_log_space(best_log_params)
        return best_pulse_params, loss_history, best_loss

    def _stage_1_parallel(
        self,
        start_params: jnp.ndarray,
        total_costs: Callable,
        total_costs_log: Callable,
        optimizer,
    ) -> Tuple[jnp.ndarray, list, jnp.ndarray]:
        """Vmap+scan Stage 1: all restarts × all steps in one XLA program.

        Always runs the full ``n_steps``: an early-stop break would
        require either chunking the scan (extra Python overhead) or
        masking updates inside the scan (no compute saved), and
        because every restart would have to plateau before we could
        break, the win is small.  Sequential mode (``n_restarts == 1``)
        does honour ``early_stop_patience``.
        """

        # (n_restarts, n_params) starting points (restart 0 unperturbed).
        params_batch = self._perturb_starts(start_params)
        log.info(
            f"Stage 1 (parallel): vmapping {self.n_restarts} restarts × "
            f"{self.n_steps} steps in a single fused program."
        )
        if self.early_stop_patience > 0:
            log.info(
                "Note: early_stop_patience is ignored in the parallel "
                "(n_restarts > 1) path; the full n_steps will run."
            )

        log_params_batch = jax.vmap(self._to_log_space)(params_batch)
        opt_state_batch = jax.vmap(optimizer.init)(log_params_batch)

        # Initial losses (per-restart) so loss_history[0] matches the
        # per-restart sequential semantics.
        init_losses = jax.vmap(total_costs)(params_batch)

        def opt_step(log_params, opt_state):
            loss, grads = jax.value_and_grad(total_costs_log)(log_params)
            updates, opt_state = optimizer.update(grads, opt_state, log_params)
            log_params = optax.apply_updates(log_params, updates)
            return log_params, opt_state, loss

        v_opt_step = jax.vmap(opt_step, in_axes=(0, 0))

        def scan_body(carry, _):
            log_params, opt_state, prev_log_params, best_loss, best_log_params = carry
            new_log_params, new_opt_state, loss = v_opt_step(log_params, opt_state)
            # Track best loss (and the params that *produced* it,
            # which are the pre-update ``prev_log_params`` — same
            # rationale as the sequential path).
            improved = loss < best_loss
            best_loss = jnp.where(improved, loss, best_loss)
            best_log_params = jnp.where(
                improved[:, None], prev_log_params, best_log_params
            )
            new_carry = (
                new_log_params,
                new_opt_state,
                log_params,  # becomes prev for the next step
                best_loss,
                best_log_params,
            )
            return new_carry, loss

        init_carry = (
            log_params_batch,
            opt_state_batch,
            log_params_batch,
            init_losses,
            log_params_batch,
        )

        @jax.jit
        def run_scan(carry):
            return jax.lax.scan(scan_body, carry, None, length=self.n_steps)

        final_carry, step_losses = run_scan(init_carry)
        # step_losses shape (n_steps, n_restarts); each row is the
        # cross-restart loss vector at one optimisation step.
        _, _, _, best_losses, best_log_params_batch = final_carry

        # Periodic batch summary so the operator still sees progress.
        # Pull the small per-step loss matrix to host once, then format
        # without further device→host transfers.
        host_step_losses = jax.device_get(step_losses)
        for step in range(0, self.n_steps, max(1, self.log_interval)):
            row = host_step_losses[step]
            log.info(
                f"Step {step}/{self.n_steps}, "
                f"loss min/mean/max: {float(row.min()):.3e} / "
                f"{float(row.mean()):.3e} / {float(row.max()):.3e}"
            )

        # Per-restart final summary (single sync for ``best_losses``).
        host_best_losses = jax.device_get(best_losses)
        for r in range(self.n_restarts):
            log.info(
                f"Restart {r + 1}/{self.n_restarts} finished "
                f"with best loss: {float(host_best_losses[r]):.3e}"
            )

        winner = int(jnp.argmin(best_losses))
        global_best_loss = best_losses[winner]
        global_best_params = self._from_log_space(best_log_params_batch[winner])

        # Build a per-step loss history for the winning restart so the
        # downstream API (and the loss-curve plot) keeps the same
        # shape as before.
        winner_history = [init_losses[winner]]
        winner_history.extend(step_losses[:, winner])
        return global_best_params, winner_history, global_best_loss

    def plot_loss_landscape(
        self,
        gate_name: str,
        grid_axes: List[jnp.ndarray],
        landscape_data: list,
    ) -> None:
        """Save a loss-landscape figure for the Phase-0 grid scan.

        The visualisation adapts to the number of pulse parameters:

        - **1 parameter**: line/scatter plot (param value vs. loss).
        - **2 parameters**: 2-D heatmap (param₀ × param₁, colour = loss).
        - **≥ 3 parameters**: horizontal scatter sorted by ascending loss
          with the best candidate highlighted.

        The figure is saved to ``{file_dir}/{gate_name}_loss_landscape.png``.

        Args:
            gate_name: Name of the gate being optimised (e.g. ``"RX"``).
            grid_axes: Per-parameter 1-D arrays that span the scan grid.
            landscape_data: List of ``(candidate_index, params, loss)``
                tuples for every successful scan candidate.
        """
        import matplotlib.pyplot as plt  # lazy — matplotlib is dev-only

        if not landscape_data:
            log.warning("plot_loss_landscape: no landscape data to plot, skipping.")
            return

        os.makedirs(self.file_dir, exist_ok=True)
        n_params = len(grid_axes)
        indices, _params_list, losses = zip(*landscape_data)
        losses_arr = np.array(losses, dtype=float)

        fig, ax = plt.subplots(figsize=(8, 5))

        if n_params == 1:
            x = np.array([float(grid_axes[0][i]) for i in indices])
            sc = ax.scatter(
                x, losses_arr, c=losses_arr, cmap="viridis_r", s=60, zorder=3
            )
            fig.colorbar(sc, ax=ax, label="Loss")
            best_i = int(np.argmin(losses_arr))
            ax.scatter(
                x[best_i],
                losses_arr[best_i],
                marker="*",
                s=200,
                color="red",
                zorder=4,
                label="best",
            )
            ax.set_xlabel("Parameter value")
            ax.set_xscale("log")
            ax.set_yscale("log")
            ax.legend()

        elif n_params == 2:
            n = self.scan_grid_size
            loss_grid = np.full((n, n), np.nan)
            for ci, _, loss in landscape_data:
                row = ci // n
                col = ci % n
                loss_grid[row, col] = loss
            masked = np.ma.masked_invalid(loss_grid)
            cmap = plt.cm.viridis_r.copy()
            cmap.set_bad(color="lightgrey")
            im = ax.imshow(
                masked,
                origin="lower",
                cmap=cmap,
                aspect="auto",
                extent=[
                    float(grid_axes[1][0]),
                    float(grid_axes[1][-1]),
                    float(grid_axes[0][0]),
                    float(grid_axes[0][-1]),
                ],
            )
            fig.colorbar(im, ax=ax, label="Loss")
            ax.set_xlabel("Parameter 1")
            ax.set_ylabel("Parameter 0")

        else:  # n_params >= 3: sorted scatter
            order = np.argsort(losses_arr)
            sorted_losses = losses_arr[order]
            sorted_indices = np.array(indices)[order]  # original trial numbers
            ranks = np.arange(len(sorted_losses))
            sc = ax.scatter(
                sorted_losses,
                ranks,
                c=sorted_indices,
                cmap="plasma",
                s=40,
                zorder=3,
            )
            fig.colorbar(sc, ax=ax, label="Trial number")
            ax.scatter(
                sorted_losses[0],
                ranks[0],
                marker="*",
                s=200,
                color="red",
                zorder=4,
                label="best",
            )
            ax.set_xlabel("Loss")
            ax.set_ylabel("Candidate rank (0 = best)")
            ax.set_xscale("log")
            ax.legend()

        ax.set_title(f"Loss Landscape (Phase 0) — {gate_name}")
        fig.tight_layout()
        path = os.path.join(self.file_dir, f"{gate_name}_loss_landscape.png")
        fig.savefig(path, dpi=150)
        plt.close(fig)
        log.info(f"Loss landscape saved to {path}")

    def plot_loss_curve(
        self,
        gate_name: str,
        loss_history: list,
    ) -> None:
        """Save a training-loss curve figure for the Phase-1 optimisation.

        Shows loss vs. optimisation step on a log y-scale with a dashed
        horizontal line at the minimum achieved loss.

        The figure is saved to ``{file_dir}/{gate_name}_loss_curve.png``.

        Args:
            gate_name: Name of the gate being optimised (e.g. ``"RX"``).
            loss_history: Sequence of loss values, one per step (including
                the initial loss at index 0).
        """
        import matplotlib.pyplot as plt  # lazy — matplotlib is dev-only

        if not loss_history:
            log.warning("plot_loss_curve: empty loss history, skipping.")
            return

        os.makedirs(self.file_dir, exist_ok=True)
        losses = [float(v) for v in loss_history]
        best = min(losses)

        fig, ax = plt.subplots(figsize=(9, 4))
        ax.plot(losses, linewidth=1.2, label="Loss")
        ax.axhline(
            best, color="red", linestyle="--", linewidth=1.0, label=f"Best: {best:.3e}"
        )
        ax.set_xlabel("Step")
        ax.set_ylabel("Loss")
        ax.set_yscale("log")
        ax.set_title(f"Training Loss (Phase 1) — {gate_name}")
        ax.legend()
        fig.tight_layout()
        path = os.path.join(self.file_dir, f"{gate_name}_loss_curve.png")
        fig.savefig(path, dpi=150)
        plt.close(fig)
        log.info(f"Loss curve saved to {path}")

    def optimize(self, wires: int) -> Callable:
        """Decorator factory that optimises pulse parameters for a gate.

        Usage::

            opt = qoc.optimize(wires=1)
            best_params, loss_history = opt(qoc.create_RX)()

        Args:
            wires: Number of qubits the gate acts on.

        Returns:
            A decorator that accepts a circuit-factory function and
            returns a callable ``(init_pulse_params=None) ->
            (best_params, loss_history)``.
        """

        def decorator(create_circuits):
            def wrapper(init_pulse_params: jnp.ndarray = None):
                """
                Optimise pulse parameters for a quantum gate using a
                multi-phase strategy:

                Stage 0 - Grid scan (if ``scan_steps > 0``):
                    Evaluate a coarse grid of parameter candidates using
                    the same weighted cost as Stage 1.  Each candidate
                    is refined with a few fast gradient steps.  The
                    best candidate becomes the starting point for
                    Stage 1, unless the user-supplied init_pulse_params
                    are already better.

                Stage 1 - Multi-restart gradient optimisation:
                    Run ``n_restarts`` independent Adam optimisation runs
                    with the full cost function.  The first restart uses
                    the best point found so far; subsequent restarts add
                    random perturbations.  Parameters at indices in
                    ``log_scale_params`` are optimised in log-space to
                    handle order-of-magnitude differences in scale.

                Args:
                    init_pulse_params (array): Initial pulse parameters.
                        If ``None``, uses the envelope defaults from
                        :class:`PulseInformation`.

                Returns:
                    tuple: ``(best_params, loss_history)`` from the best
                        restart.
                """
                pulse_circuit, target_circuit = create_circuits()

                # Build a second pair that prepends a Hadamard on every
                # wire so the cost is also evaluated from the
                # ``|+⟩^⊗n`` initial state.  Probing two non-collinear
                # initial states exposes rotation-axis tilt to the
                # optimiser: an RX/RY pulse with a residual Z component
                # is partly degenerate from ``|0⟩`` alone but produces
                # a clearly distinguishable trajectory from ``|+⟩``.
                # Both circuits get the same preparation so the target
                # remains exact.
                def _with_plus_prep(circuit_fn):
                    def prepared(*args, **kwargs):
                        for q in range(wires):
                            op.H(wires=q)
                        circuit_fn(*args, **kwargs)

                    prepared.__name__ = f"plus_{circuit_fn.__name__}"
                    return prepared

                pulse_circuit_plus = _with_plus_prep(pulse_circuit)
                target_circuit_plus = _with_plus_prep(target_circuit)

                pulse_scripts = [
                    js.Script(pulse_circuit, n_qubits=wires),
                    js.Script(pulse_circuit_plus, n_qubits=wires),
                ]
                target_scripts = [
                    js.Script(target_circuit, n_qubits=wires),
                    js.Script(target_circuit_plus, n_qubits=wires),
                ]

                d_basis = 2**wires
                pulse_basis_scripts = [
                    js.Script(_with_basis_prep(pulse_circuit, k, wires), n_qubits=wires)
                    for k in range(d_basis)
                ]
                target_basis_scripts = [
                    js.Script(
                        _with_basis_prep(target_circuit, k, wires), n_qubits=wires
                    )
                    for k in range(d_basis)
                ]

                gate_name = create_circuits.__name__.split("_")[1]

                if init_pulse_params is None:
                    init_pulse_params = PulseInformation.gate_by_name(gate_name).params
                log.debug(
                    f"Initial pulse parameters for {gate_name}: {init_pulse_params}"
                )

                all_ckwargs = {
                    "pulse_scripts": pulse_scripts,
                    "target_scripts": target_scripts,
                    "pulse_basis_scripts": pulse_basis_scripts,
                    "target_basis_scripts": target_basis_scripts,
                    "envelope": self.envelope,
                    "n_samples": self.n_samples,
                    "n_qubits": wires,
                    "t_target": self.t_target,
                }

                def _build_cost(name, weight):
                    """Build a Cost from a registry entry, filtering ckwargs."""
                    meta = CostFnRegistry.get(name)
                    return Cost(
                        cost=meta["fn"],
                        weight=weight,
                        ckwargs={
                            k: v
                            for k, v in all_ckwargs.items()
                            if k in meta["ckwargs_keys"]
                        },
                    )

                total_costs = None
                for name, weight in self.cost_fns:
                    total_costs = _build_cost(name, weight) + total_costs

                best_scan_params, scan_data = self.stage_0_opt(
                    init_pulse_params,
                    total_costs,
                )

                global_best_params, global_best_history, global_best_loss = (
                    self.stage_1_opt(
                        best_scan_params,
                        total_costs,
                    )
                )
                self.save_results(
                    gate=gate_name,
                    fidelity=1 - global_best_loss.item(),
                    pulse_params=global_best_params,
                )

                if self.plot:
                    if scan_data is not None:
                        grid_axes, landscape_items = scan_data
                        self.plot_loss_landscape(gate_name, grid_axes, landscape_items)
                    self.plot_loss_curve(gate_name, global_best_history)

                return global_best_params, global_best_history

            return wrapper

        return decorator

    # ------------------------------------------------------------------
    # Per-gate (pulse, target) circuit factories
    # ------------------------------------------------------------------
    #
    # Each entry maps a gate name to a ``(pulse_circuit, target_circuit)``
    # pair.  The per-gate variants prepend a symmetry-breaking
    # preparation (e.g. ``op.H``/``op.RY``) so the *state-vector* cost
    # is sensitive to rotation-axis tilt.  The joint-mode variants drop
    # those preps because the unitary cost already captures axis tilt
    # without probe-state trickery (see :meth:`_create_joint_pair_for`).

    @staticmethod
    def _gate_factories() -> Dict[str, Tuple[Callable, Callable]]:
        """Return the ``{gate_name: (pulse_fn, target_fn)}`` table.

        Constructed lazily inside a staticmethod so the closures
        capture the imported gate symbols at call time.
        """

        return {
            "RX": _make_gate_pair(
                lambda w, pp: Gates.RX(w, 0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RX(w, wires=0),
            ),
            "RY": _make_gate_pair(
                lambda w, pp: Gates.RY(w, 0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RY(w, wires=0),
            ),
            "RZ": _make_gate_pair(
                lambda w, pp: Gates.RZ(w, 0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RZ(w, wires=0),
                prep=lambda w: op.H(wires=0),
                post=lambda w: op.H(wires=0),
            ),
            "H": _make_gate_pair(
                lambda w, pp: Gates.H(0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.H(wires=0),
                prep=lambda w: op.RY(w, wires=0),
            ),
            "Rot": _make_gate_pair(
                lambda w, pp: Gates.Rot(
                    w, w * 2, w * 3, 0, pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.Rot(w, w * 2, w * 3, wires=0),
                prep=lambda w: op.H(wires=0),
            ),
            "CX": _make_gate_pair(
                lambda w, pp: Gates.CX(
                    wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CX(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: op.RY(w, wires=0),
                    lambda w: op.H(wires=1),
                ),
            ),
            "CY": _make_gate_pair(
                lambda w, pp: Gates.CY(
                    wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CY(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: op.RX(w, wires=0),
                    lambda w: op.H(wires=1),
                ),
            ),
            "CZ": _make_gate_pair(
                lambda w, pp: Gates.CZ(
                    wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CZ(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: op.RY(w, wires=0),
                    lambda w: op.H(wires=1),
                ),
            ),
            "CRX": _make_gate_pair(
                lambda w, pp: Gates.CRX(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRX(w, wires=[0, 1]),
                prep=lambda w: op.H(wires=0),
            ),
            "CRY": _make_gate_pair(
                lambda w, pp: Gates.CRY(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRY(w, wires=[0, 1]),
                prep=lambda w: op.H(wires=0),
            ),
            "CRZ": _make_gate_pair(
                lambda w, pp: Gates.CRZ(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRZ(w, wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: op.H(wires=0),
                    lambda w: op.H(wires=1),
                ),
            ),
        }

    @staticmethod
    def _joint_gate_factories() -> Dict[str, Tuple[Callable, Callable]]:
        """``(pulse, target)`` pairs without any symmetry-breaking preps.

        Used by :meth:`_create_joint_pair_for`: the unitary cost
        already exposes rotation-axis tilt without a probe state, and
        leaving the preps in actively *hides* certain errors (e.g.
        ``op.H(wires=1)`` puts the target qubit of CX into a CX
        eigenstate, so the column-stacked unitary becomes insensitive
        to the pulse error).  ``Rot`` and ``CY`` are intentionally
        absent because the joint optimiser does not target them.
        """

        return {
            "RX": _make_gate_pair(
                lambda w, pp: Gates.RX(w, wires=0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RX(w, wires=0),
            ),
            "RY": _make_gate_pair(
                lambda w, pp: Gates.RY(w, wires=0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RY(w, wires=0),
            ),
            "RZ": _make_gate_pair(
                lambda w, pp: Gates.RZ(w, wires=0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.RZ(w, wires=0),
            ),
            "H": _make_gate_pair(
                lambda w, pp: Gates.H(0, pulse_params=pp, gate_mode="pulse"),
                lambda w: op.H(wires=0),
            ),
            "CZ": _make_gate_pair(
                lambda w, pp: Gates.CZ(
                    wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CZ(wires=[0, 1]),
            ),
            "CX": _make_gate_pair(
                lambda w, pp: Gates.CX(
                    wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CX(wires=[0, 1]),
            ),
            "CRX": _make_gate_pair(
                lambda w, pp: Gates.CRX(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRX(w, wires=[0, 1]),
            ),
            "CRY": _make_gate_pair(
                lambda w, pp: Gates.CRY(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRY(w, wires=[0, 1]),
            ),
            "CRZ": _make_gate_pair(
                lambda w, pp: Gates.CRZ(
                    w, wires=[0, 1], pulse_params=pp, gate_mode="pulse"
                ),
                lambda w: op.CRZ(w, wires=[0, 1]),
            ),
        }

    def _create_pair(self, gate_name: str) -> Tuple[Callable, Callable]:
        """Look up the per-gate ``(pulse, target)`` pair from the table."""
        try:
            return self._gate_factories()[gate_name]
        except KeyError as exc:
            raise ValueError(f"No factory for gate {gate_name!r}.") from exc

    # Thin compatibility wrappers around :meth:`_create_pair` so existing
    # code (and tests) that call ``qoc.create_<gate>`` keep working.
    def create_RX(self):
        return self._create_pair("RX")

    def create_RY(self):
        return self._create_pair("RY")

    def create_RZ(self):
        return self._create_pair("RZ")

    def create_H(self):
        return self._create_pair("H")

    def create_Rot(self):
        return self._create_pair("Rot")

    def create_CX(self):
        return self._create_pair("CX")

    def create_CY(self):
        return self._create_pair("CY")

    def create_CZ(self):
        return self._create_pair("CZ")

    def create_CRX(self):
        return self._create_pair("CRX")

    def create_CRY(self):
        return self._create_pair("CRY")

    def create_CRZ(self):
        return self._create_pair("CRZ")

    def create_CPhase(self):
        """Create pulse and target circuits for the CPhase gate."""

        def pulse_circuit(w, pulse_params):
            op.H(wires=0)
            op.H(wires=1)
            Gates.CPhase(w, wires=[0, 1], pulse_params=pulse_params, gate_mode="pulse")

        def target_circuit(w):
            op.H(wires=0)
            op.H(wires=1)
            op.ControlledPhaseShift(w, wires=[0, 1])

        return pulse_circuit, target_circuit

    def optimize_all(self, sel_gates: str, make_log: bool) -> None:
        """Optimise all selected gates and optionally write a log CSV.

        Args:
            sel_gates: Comma-separated gate names or ``"all"``.
            make_log: If ``True``, write per-gate loss histories to
                ``qml_essentials/qoc_logs.csv``.
        """
        # Joint mode (Round 3) is now implemented in :meth:`optimize_joint`.
        # The `--joint` CLI flag selects it instead of this per-gate loop.
        log_history: Dict[str, list] = {}

        for gate in self.GATES_1Q + self.GATES_2Q:
            if gate in sel_gates or "all" in sel_gates:
                n_wires = 1 if gate in self.GATES_1Q else 2
                opt = self.optimize(wires=n_wires)
                gate_factory = getattr(self, f"create_{gate}")
                log.info(f"Optimizing {gate} gate...")
                optimized_pulse_params, loss_history = opt(gate_factory)()
                log.info(f"Optimized parameters for {gate}: {optimized_pulse_params}")
                best_fid = 1 - min(float(loss) for loss in loss_history)
                log.info(f"Best achieved fidelity: {best_fid * 100:.5f}%")
                log_history[gate] = log_history.get(gate, []) + loss_history

        if make_log:
            # write log history to file
            with open("qml_essentials/qoc_logs.csv", "w") as f:
                writer = csv.writer(f)
                writer.writerow(log_history.keys())
                writer.writerows(zip(*log_history.values()))

    # ------------------------------------------------------------------
    # Joint composite-aware optimisation (Round 3)
    # ------------------------------------------------------------------

    # Default leaf set whose parameters are jointly optimised.  Order
    # matters — it determines the layout of the joint parameter vector
    # (theta).  Excluding a leaf from this list freezes it at its
    # current PulseInformation default during joint optimisation.
    JOINT_LEAVES_DEFAULT: Tuple[str, ...] = ("RX", "RY", "RZ", "CZ")

    # Default set of target gates whose unitary cost is summed during
    # joint optimisation.  Composite gates back-propagate into the
    # shared leaves; leaf-gate terms keep the standalone fidelity
    # acceptable.  CZ is excluded from the default targets because it
    # is implemented as a static diagonal-Hamiltonian evolution
    # (``H_CZ = π·|11⟩⟨11|``, t=1) that is structurally exact and
    # cannot be improved by tuning leaf parameters — including it only
    # adds ballast to the averaged loss.
    JOINT_TARGETS_DEFAULT: Tuple[str, ...] = (
        "RX",
        "RY",
        "RZ",
        "H",
        "CX",
        "CRX",
        "CRY",
        "CRZ",
    )

    # Default per-target weights for the joint objective.  Weights are
    # normalised inside :func:`joint_unitary_cost_fn`.  Composites are
    # up-weighted because (a) they are what fails the tightened tests
    # and (b) standalone leaves already start near-perfect, so the
    # averaged loss would otherwise be dominated by the cheap leaves
    # and the optimiser would happily refuse to move.  Within
    # composites, CR_ are weighted higher than H/CX because they are
    # the longest decompositions (2 CX + ~6 single-qubit gates) so
    # their leaf-error compounding is worst.
    JOINT_WEIGHTS_DEFAULT: Dict[str, float] = {
        "RX": 0.3,
        "RY": 0.3,
        "RZ": 0.3,
        "H": 1.0,
        "CX": 2.0,
        "CRX": 3.0,
        "CRY": 3.0,
        "CRZ": 3.0,
    }

    # Leaves that are physically identical up to a static carrier-phase
    # offset (RX uses cos(ω_c t), RY uses cos(ω_c t + π/2)) and therefore
    # *should* share the same envelope parameters.  Tying them here in
    # the QOC layout — rather than in :mod:`pulses` — keeps the per-gate
    # decomposition tree intact while ensuring joint optimisation cannot
    # drift their envelopes apart.  Empirically RY is the dominant
    # contributor to H/CX residuals, so leaving it un-tied lets the
    # joint loss settle into a basin where RX is well-tuned but RY is
    # ~3× worse; tying them removes that asymmetry.
    JOINT_TIED_GROUPS_DEFAULT: Tuple[Tuple[str, ...], ...] = (("RX", "RY"),)

    def _build_joint_layout(
        self,
        leaf_names: Tuple[str, ...],
        tied_groups: Optional[Tuple[Tuple[str, ...], ...]] = None,
    ) -> Tuple[jnp.ndarray, Dict[str, slice], List[int]]:
        """Build the joint parameter layout.

        Args:
            leaf_names: Ordered names of the leaf gates that participate
                in the joint optimisation.
            tied_groups: Optional tuple of leaf-name groups whose
                parameters are forced to share a single slice in
                ``theta``.  Defaults to
                :pyattr:`JOINT_TIED_GROUPS_DEFAULT` (ties RX/RY).  Only
                leaves that are present in ``leaf_names`` participate —
                a group becomes a no-op if fewer than two of its
                members are listed.

        Returns:
            Tuple ``(init_theta, leaf_slices, log_scale_indices)``:
              * ``init_theta`` — concatenated init parameters from
                ``PulseInformation.<leaf>.params`` in the given order.
                For tied groups, the representative leaf is the *first*
                member in the group (the group's mean of current params
                is used as the shared init so neither side dominates).
              * ``leaf_slices`` — mapping leaf-name → ``slice`` into
                ``init_theta``.  Tied leaves map to the *same* slice.
              * ``log_scale_indices`` — indices into ``init_theta`` that
                should be optimised in log-space (amplitude + evolution
                time per envelope leaf, mirroring the per-gate default
                ``[0, -1]`` rule).
        """
        if tied_groups is None:
            tied_groups = self.JOINT_TIED_GROUPS_DEFAULT

        # Build leaf_name -> representative_name lookup.  Members of a
        # tied group are routed to the group's first member that is
        # actually present in ``leaf_names``.
        rep_of: Dict[str, str] = {n: n for n in leaf_names}
        leaf_set = set(leaf_names)
        for group in tied_groups:
            present = [n for n in group if n in leaf_set]
            if len(present) < 2:
                continue
            head = present[0]
            for member in present[1:]:
                rep_of[member] = head
                log.info(
                    f"  Joint layout: tying leaf {member!r} to {head!r} "
                    f"(shared slice in theta)."
                )

        envelope_info = PulseEnvelope.get(self.envelope)
        n_env = envelope_info["n_envelope_params"]

        leaf_slices: Dict[str, slice] = {}
        init_chunks = []
        log_idx: List[int] = []
        offset = 0
        for name in leaf_names:
            rep = rep_of[name]
            if rep != name:
                # Tied member — point at the representative's slice.
                leaf_slices[name] = leaf_slices[rep]
                continue

            pp = PulseInformation.gate_by_name(name)
            assert pp is not None and pp.is_leaf, (
                f"_build_joint_layout: {name!r} is not a leaf gate"
            )
            # For tied groups the shared init is the elementwise mean
            # of the current params across all present members; this
            # avoids biasing toward whichever member happens to be the
            # group representative.
            tied_members = [m for m in leaf_names if rep_of[m] == name]
            if len(tied_members) > 1:
                stacked = jnp.stack(
                    [
                        jnp.asarray(
                            PulseInformation.gate_by_name(m).params,
                            dtype=jnp.float64,
                        )
                        for m in tied_members
                    ]
                )
                chunk = jnp.mean(stacked, axis=0)
            else:
                chunk = jnp.asarray(pp.params, dtype=jnp.float64)
            n_p = chunk.shape[0]
            leaf_slices[name] = slice(offset, offset + n_p)
            init_chunks.append(chunk)
            # Log-scale rule per leaf: only leaves that come from the
            # *envelope* (RX, RY) get log-scaled amplitude+time.  RZ
            # and CZ use the "general" registry with a single tuning
            # scalar — leave them in linear space.
            if name in ("RX", "RY") and n_env >= 2:
                log_idx.append(offset)  # amplitude
                log_idx.append(offset + n_p - 1)  # evolution time
            offset += n_p

        init_theta = jnp.concatenate(init_chunks)
        return init_theta, leaf_slices, log_idx

    @staticmethod
    def _assemble_for_gate(
        theta: jnp.ndarray,
        pp_obj,
        leaf_slices: Dict[str, slice],
    ) -> jnp.ndarray:
        """Assemble the per-gate flat ``pulse_params`` from ``theta``.

        Walks the gate's decomposition tree (recursing through
        composites) and concatenates the appropriate slice of ``theta``
        for each leaf occurrence.  Mirrors :pyattr:`PulseParams.params`
        getter logic but pulls leaf data from the joint vector
        ``theta`` rather than the leaves' own ``_params``.
        """
        if pp_obj.is_leaf:
            sl = leaf_slices.get(pp_obj.name)
            if sl is None:
                # Leaf is frozen — use its current PulseInformation
                # value directly.
                return jnp.asarray(pp_obj.params, dtype=jnp.float64)
            return theta[sl]
        return jnp.concatenate(
            [
                QOC._assemble_for_gate(theta, child, leaf_slices)
                for child in pp_obj.childs
            ]
        )

    def _joint_stage_0_coord_descent(
        self,
        init_theta: jnp.ndarray,
        leaf_slices: Dict[str, slice],
        total_cost: Callable,
    ) -> jnp.ndarray:
        """Coordinate-descent grid scan over leaf-axis blocks.

        For each leaf in ``leaf_slices`` (in order), sweep a centred
        multiplicative grid over that leaf's params (using the existing
        :meth:`_build_scan_grid` machinery) while holding the other
        leaves at the current best.  Greedily accept any improvement.

        This avoids the combinatorial explosion of a Cartesian
        product over all leaf axes simultaneously: instead of
        ``Π_i scan_grid_size**k_i`` candidates, only ``Σ_i
        scan_grid_size**k_i`` are evaluated.

        Args:
            init_theta: Starting joint parameter vector.
            leaf_slices: Mapping leaf-name → slice into ``init_theta``.
            total_cost: Joint cost callable taking ``theta`` and
                returning a scalar loss.

        Returns:
            Best joint parameter vector found.
        """
        if self.scan_steps <= 0:
            log.info("Joint Stage 0: scan disabled (scan_steps=0); skipping.")
            return init_theta

        current = init_theta
        best_loss = _safe_eval(total_cost, current)
        log.info(
            f"Joint Stage 0: coordinate-descent over {len(leaf_slices)} leaves, "
            f"init_loss={float(best_loss):.6e}"
        )

        prev_solver_defaults = js.Evolution.set_solver_defaults(throw=False)
        try:
            seen_slices: set = set()
            for leaf_name, sl in leaf_slices.items():
                # Tied leaves share a slice — only scan the unique
                # (start, stop) range once to avoid wasted evaluations.
                key = (sl.start, sl.stop)
                if key in seen_slices:
                    continue
                seen_slices.add(key)
                leaf_init = current[sl]
                n_p = int(leaf_init.shape[0])
                if n_p == 0:
                    continue
                grid, _ = self._build_scan_grid(n_p, init_pulse_params=leaf_init)
                n_better = 0
                for cand in grid:
                    new_theta = current.at[sl].set(cand)
                    loss = _safe_eval(total_cost, new_theta)
                    if loss < best_loss:
                        best_loss = loss
                        current = new_theta
                        n_better += 1
                log.info(
                    f"  Joint scan after leaf {leaf_name} "
                    f"({len(grid)} candidates, {n_better} improved): "
                    f"best_loss={float(best_loss):.6e}"
                )
        finally:
            if prev_solver_defaults:
                js.Evolution.set_solver_defaults(**prev_solver_defaults)

        return current

    def _create_joint_pair_for(self, gate_name: str):
        """Return a prep-free ``(pulse, target)`` pair for joint mode.

        Looks up :meth:`_joint_gate_factories` first; falls back to the
        per-gate (preps included) variant via :meth:`_create_pair_for`
        with a warning if the gate is not in the joint table.  See the
        joint-table docstring for why preps are dropped.
        """
        table = self._joint_gate_factories()
        if gate_name in table:
            return table[gate_name]
        log.warning(
            f"_create_joint_pair_for: no prep-free factory for {gate_name!r}; "
            f"falling back to create_{gate_name} (preps may hide errors)."
        )
        return self._create_pair_for(gate_name)

    def _create_pair_for(self, gate_name: str):
        """Return ``(pulse_circuit, target_circuit)`` for a target gate.

        Reuses :meth:`_create_pair` so the joint mode targets exactly
        the same circuits as the per-gate mode.
        """
        return self._create_pair(gate_name)

    def optimize_joint(
        self,
        target_gates: Optional[List[str]] = None,
        leaf_names: Optional[List[str]] = None,
        weights: Optional[Dict[str, float]] = None,
    ) -> Tuple[jnp.ndarray, Dict[str, slice], list]:
        """Joint composite-aware optimisation of leaf pulse parameters.

        Optimises a single shared parameter vector ``theta`` (containing
        the concatenated leaf params for ``leaf_names``) against a
        weighted sum of unitary-cost terms over ``target_gates``.
        Composite gates back-propagate into the shared leaves; leaf
        terms keep the standalone fidelity acceptable.  CZ is omitted
        from the default targets because the ``PulseGates.CZ``
        implementation is a static diagonal-Hamiltonian evolution
        (``H_CZ = π·|11⟩⟨11|``, t=1) that is structurally exact and
        unaffected by any leaf re-tuning.

        Args:
            target_gates: Gates whose unitary cost contributes to the
                joint objective.  Defaults to
                :pyattr:`JOINT_TARGETS_DEFAULT` (RX, RY, RZ, H, CX,
                CRX, CRY, CRZ).
            leaf_names: Leaf gates whose parameters are jointly
                optimised.  Defaults to :pyattr:`JOINT_LEAVES_DEFAULT`
                (RX, RY, RZ, CZ).
            weights: Optional mapping ``gate_name → weight``.  Merged
                on top of :pyattr:`JOINT_WEIGHTS_DEFAULT` (composites
                up-weighted; leaves down-weighted).  All weights are
                normalised inside the cost.

        Returns:
            ``(best_theta, leaf_slices, loss_history)``.  Per-leaf
            results are also written to ``qoc_results_<envelope>.csv``
            via :meth:`save_results`.
        """
        if target_gates:
            target_gates = list(target_gates)
        else:
            target_gates = list(self.JOINT_TARGETS_DEFAULT)

        if leaf_names:
            leaf_names = list(leaf_names)
        else:
            leaf_names = list(self.JOINT_LEAVES_DEFAULT)

        # Merge user-provided weights on top of class defaults so callers
        # can override only the gates they care about.
        merged_weights: Dict[str, float] = dict(self.JOINT_WEIGHTS_DEFAULT)
        if weights:
            merged_weights.update({k: float(v) for k, v in weights.items()})
        weights = merged_weights

        log.info(f"Joint optimisation: leaves={leaf_names}, targets={target_gates}")

        init_theta, leaf_slices, joint_log_idx = self._build_joint_layout(
            tuple(leaf_names)
        )
        log.info(
            f"  Joint theta size: {init_theta.shape[0]}; "
            f"log-scale indices: {joint_log_idx}"
        )

        # Build per-gate specs (assembler + basis-prep scripts).
        gate_specs: List[dict] = []
        for gname in target_gates:
            pp_obj = PulseInformation.gate_by_name(gname)
            if pp_obj is None:
                log.warning(f"  Skipping unknown gate {gname!r}.")
                continue
            n_wires = 1 if gname in self.GATES_1Q else 2
            d_basis = 2**n_wires
            pulse_circuit, target_circuit = self._create_joint_pair_for(gname)

            pulse_basis_scripts = [
                js.Script(_with_basis_prep(pulse_circuit, k, n_wires), n_qubits=n_wires)
                for k in range(d_basis)
            ]
            target_basis_scripts = [
                js.Script(
                    _with_basis_prep(target_circuit, k, n_wires), n_qubits=n_wires
                )
                for k in range(d_basis)
            ]

            # Closure capturing pp_obj + leaf_slices.  Defined here so
            # each spec carries its own assembler.
            def _make_assembler(pp_obj=pp_obj):
                def assemble(theta):
                    return QOC._assemble_for_gate(theta, pp_obj, leaf_slices)

                return assemble

            gate_specs.append(
                {
                    "name": gname,
                    "n_qubits": n_wires,
                    "weight": float(weights.get(gname, 1.0)),
                    "assembler": _make_assembler(),
                    "pulse_basis_scripts": pulse_basis_scripts,
                    "target_basis_scripts": target_basis_scripts,
                }
            )
            log.info(
                f"  Built spec for {gname}: n_qubits={n_wires}, "
                f"weight={gate_specs[-1]['weight']}"
            )

        # Build the joint cost as a Cost wrapper (so weight-tuple
        # collapsing into a scalar is shared with the per-gate path).
        # We use the same (process_loss, phase_loss) two-component
        # weighting as the standalone unitary cost — keeps the relative
        # importance of fidelity vs phase consistent.
        ((_, weight_tuple),) = (
            ((n, w) for n, w in self.cost_fns if n == "unitary")
            if any(n == "unitary" for n, _ in self.cost_fns)
            else ((None, (0.5, 0.5)),)
        )
        joint_cost = Cost(
            cost=joint_unitary_cost_fn,
            weight=weight_tuple,
            ckwargs={
                "gate_specs": gate_specs,
                "n_samples": self.n_samples,
            },
        )

        # Temporarily override log_scale_params to point at joint
        # vector indices (Stage 0 grid building + Stage 1 log-space
        # reparam both consult ``self.log_scale_params``).  Invalidate
        # the mask cache on either side of the swap so the joint
        # vector picks up the joint indices and per-gate runs revert
        # cleanly afterwards.
        prev_log_scale = self.log_scale_params
        self.log_scale_params = joint_log_idx
        self._log_mask_cache.clear()
        try:
            best_scan_theta = self._joint_stage_0_coord_descent(
                init_theta, leaf_slices, joint_cost
            )

            global_best_theta, global_best_history, global_best_loss = self.stage_1_opt(
                best_scan_theta, joint_cost
            )
        finally:
            self.log_scale_params = prev_log_scale
            self._log_mask_cache.clear()

        log.info(f"Joint optimisation done. final loss={float(global_best_loss):.6e}")

        # Save per-leaf results to the CSV (one row per leaf).  The
        # fidelity column carries the *joint* fidelity; downstream code
        # that reads the CSV (or the user copy-pasting into pulses.py)
        # can use it as a coarse quality signal.
        joint_fid = float(1.0 - global_best_loss)
        for leaf_name, sl in leaf_slices.items():
            leaf_params = global_best_theta[sl]
            self.save_results(
                gate=leaf_name,
                fidelity=joint_fid,
                pulse_params=leaf_params,
            )

        # Update PulseInformation in-place so the new defaults are
        # active in this Python process (handy for diagnostic scripts
        # that import QOC and then evaluate the new gates).
        for leaf_name, sl in leaf_slices.items():
            pp = PulseInformation.gate_by_name(leaf_name)
            pp.params = global_best_theta[sl]

        return global_best_theta, leaf_slices, global_best_history

__init__(envelope, cost_fns, t_target, n_steps, n_samples, learning_rate, log_interval=50, file_dir=None, warmup_ratio=0.0, end_lr_ratio=1.0, n_restarts=1, restart_noise_scale=0.5, grad_clip=1.0, random_seed=42, scan_steps=0, scan_grid_size=5, scan_ranges=None, log_scale_params=None, early_stop_patience=0, early_stop_min_delta=0.0, plot=False) #

Initialize Quantum Optimal Control with Pulse-level Gates.

Parameters:

Name Type Description Default
envelope str

Pulse envelope shape to use for optimization. Must be one of the registered envelopes in PulseEnvelope (e.g. 'gaussian', 'square', 'cosine', 'drag', 'sech').

required
cost_fns list

List of (name, weight) tuples that select which cost functions to use and their weights. name must be a key in :class:CostFnRegistry. weight is either a single float or a tuple of floats matching the number of return values of the cost function.

required
t_target float

Target evolution time for the evolution_time cost function. Required when "evolution_time" is among the selected cost functions.

required
n_steps int

Number of steps in optimization.

required
n_samples int

Number of parameter samples per step.

required
learning_rate float

Peak learning rate for AdamW. When a warmup/decay schedule is active this is the maximum LR reached after the warmup phase.

required
log_interval int

Interval for logging.

50
file_dir str

Directory to save results.

None
warmup_ratio float

Fraction of n_steps used for linear warmup (0.0 - 1.0). Set to 0.0 to disable warmup and use a constant learning rate throughout. A value of e.g. 0.05 means the first 5 % of steps linearly ramp the LR from end_lr_ratio * learning_rate to learning_rate.

0.0
end_lr_ratio float

The final learning rate is end_lr_ratio * learning_rate. Also used as the initial LR at the start of warmup. Set to 0.0 for full cosine decay to zero; set to 1.0 (together with warmup_ratio=0.0) to recover a constant LR.

1.0
n_restarts int

Number of random restarts for the optimisation. The first run uses the initial parameters as-is; subsequent runs add scaled random perturbations. The best result across all restarts is kept. Set to 1 to disable restarts (default behaviour).

1
restart_noise_scale float

Standard deviation of the Gaussian noise added to the initial parameters for each restart (relative to the absolute value of each parameter). Defaults to 0.5 (50 % relative perturbation). Note that the package-level default in default_qoc_params is a much smaller 0.01 because the QOC loss landscape is highly sensitive to initial conditions and large perturbations routinely move restarts into useless basins; tune up only if you have reason to believe the initial point is far from any good basin.

0.5
grad_clip float

Maximum global gradient norm. Gradients are clipped to this value before being passed to the optimiser, which stabilises training when the loss landscape has steep regions. Set to float('inf') or 0.0 to disable. Defaults to 1.0.

1.0
random_seed int

Base random seed for generating restart perturbations. Defaults to 42.

42
scan_steps int

Number of short gradient-descent steps to run for each candidate in the coarse grid search (Stage 0). Set to 0 to disable the grid scan entirely and rely solely on restarts. A value of 20-50 is usually enough to identify promising basins. Defaults to 0.

0
scan_grid_size int

Number of points per parameter dimension in the coarse grid. The total number of candidates is scan_grid_size ** n_params, so keep this small for high-dimensional parameter spaces. Defaults to 5.

5
scan_ranges Optional[List[Tuple[float, float]]]

Per- parameter (lo, hi) ranges for the grid scan. If None, heuristic ranges are used based on the envelope type: amplitude in [0.5, 30], width/sigma in [0.05, 2], and evolution time in [0.05, 2]. Must have length equal to the number of pulse parameters if provided.

None
log_scale_params Optional[List[int]]

Indices of pulse parameters that should be optimised in log-space. For these parameters the optimizer sees log(p) and the actual parameter used in the simulation is exp(log_p). This dramatically improves convergence when the optimal value may differ from the initial value by an order of magnitude (e.g. amplitude, evolution time). If None, defaults to [0, -1] (amplitude and evolution time) for envelopes with ≥ 2 envelope params, or [] otherwise.

None
early_stop_patience int

Number of consecutive Stage-1 steps with no improvement greater than early_stop_min_delta after which optimisation exits early. Set to 0 (default) to disable. Only honoured in the single-restart (sequential) path; when n_restarts > 1 the parallel vmap+scan path always runs the full n_steps.

0
early_stop_min_delta float

Minimum decrease in loss that counts as an improvement for the early-stopping patience counter. Defaults to 0.0 (any strict improvement resets the counter).

0.0
plot bool

If True, save a loss-landscape figure after Phase 0 and a loss-curve figure after Phase 1 to file_dir. Requires matplotlib to be installed. Defaults to False.

False
Source code in qml_essentials/qoc.py
def __init__(
    self,
    envelope: str,
    cost_fns: List[Tuple[str, Union[float, Tuple[float, ...]]]],
    t_target: float,
    n_steps: int,
    n_samples: int,
    learning_rate: float,
    log_interval: int = 50,
    file_dir: str = None,
    warmup_ratio: float = 0.0,
    end_lr_ratio: float = 1.0,
    n_restarts: int = 1,
    restart_noise_scale: float = 0.5,
    grad_clip: float = 1.0,
    random_seed: int = 42,
    scan_steps: int = 0,
    scan_grid_size: int = 5,
    scan_ranges: Optional[List[Tuple[float, float]]] = None,
    log_scale_params: Optional[List[int]] = None,
    early_stop_patience: int = 0,
    early_stop_min_delta: float = 0.0,
    plot: bool = False,
):
    """
    Initialize Quantum Optimal Control with Pulse-level Gates.

    Args:
        envelope (str): Pulse envelope shape to use for optimization.
            Must be one of the registered envelopes in PulseEnvelope
            (e.g. 'gaussian', 'square', 'cosine', 'drag', 'sech').
        cost_fns (list): List of ``(name, weight)`` tuples that select
            which cost functions to use and their weights.  name must
            be a key in :class:`CostFnRegistry`.  *weight* is either a
            single float or a tuple of floats matching the number of
            return values of the cost function.
        t_target (float, optional): Target evolution time for the
            ``evolution_time`` cost function.  Required when
            ``"evolution_time"`` is among the selected cost functions.
        n_steps (int): Number of steps in optimization.
        n_samples (int): Number of parameter samples per step.
        learning_rate (float): Peak learning rate for AdamW. When a
            warmup/decay schedule is active this is the maximum LR
            reached after the warmup phase.
        log_interval (int): Interval for logging.
        file_dir (str): Directory to save results.
        warmup_ratio (float): Fraction of ``n_steps`` used for linear
            warmup (0.0 - 1.0).  Set to 0.0 to disable warmup and use
            a constant learning rate throughout.  A value of e.g. 0.05
            means the first 5 % of steps linearly ramp the LR from
            ``end_lr_ratio * learning_rate`` to ``learning_rate``.
        end_lr_ratio (float): The final learning rate is
            ``end_lr_ratio * learning_rate``.  Also used as the initial
            LR at the start of warmup.  Set to 0.0 for full cosine
            decay to zero; set to 1.0 (together with
            ``warmup_ratio=0.0``) to recover a constant LR.
        n_restarts (int): Number of random restarts for the
            optimisation.  The first run uses the initial parameters
            as-is; subsequent runs add scaled random perturbations.
            The best result across all restarts is kept.
            Set to 1 to disable restarts (default behaviour).
        restart_noise_scale (float): Standard deviation of the
            Gaussian noise added to the initial parameters for each
            restart (relative to the absolute value of each parameter).
            Defaults to 0.5 (50 % relative perturbation).  Note that
            the package-level default in ``default_qoc_params`` is a
            much smaller ``0.01`` because the QOC loss landscape is
            highly sensitive to initial conditions and large
            perturbations routinely move restarts into useless
            basins; tune up only if you have reason to believe the
            initial point is far from any good basin.
        grad_clip (float): Maximum global gradient norm.  Gradients
            are clipped to this value before being passed to the
            optimiser, which stabilises training when the loss
            landscape has steep regions.  Set to ``float('inf')`` or
            0.0 to disable.  Defaults to 1.0.
        random_seed (int): Base random seed for generating restart
            perturbations.  Defaults to 42.
        scan_steps (int): Number of short gradient-descent steps to
            run for each candidate in the coarse grid search
            (Stage 0).  Set to 0 to disable the grid scan entirely
            and rely solely on restarts.  A value of 20-50 is
            usually enough to identify promising basins.  Defaults
            to 0.
        scan_grid_size (int): Number of points per parameter
            dimension in the coarse grid.  The total number of
            candidates is ``scan_grid_size ** n_params``, so keep
            this small for high-dimensional parameter spaces.
            Defaults to 5.
        scan_ranges (Optional[List[Tuple[float, float]]]): Per-
            parameter ``(lo, hi)`` ranges for the grid scan.  If
            ``None``, heuristic ranges are used based on the
            envelope type: amplitude in ``[0.5, 30]``, width/sigma
            in ``[0.05, 2]``, and evolution time in ``[0.05, 2]``.
            Must have length equal to the number of pulse parameters
            if provided.
        log_scale_params (Optional[List[int]]): Indices of pulse
            parameters that should be optimised in log-space.  For
            these parameters the optimizer sees ``log(p)`` and the
            actual parameter used in the simulation is ``exp(log_p)``.
            This dramatically improves convergence when the optimal
            value may differ from the initial value by an order of
            magnitude (e.g. amplitude, evolution time).
            If ``None``, defaults to ``[0, -1]`` (amplitude and
            evolution time) for envelopes with ≥ 2 envelope params,
            or ``[]`` otherwise.
        early_stop_patience (int): Number of consecutive
            Stage-1 steps with no improvement greater than
            ``early_stop_min_delta`` after which optimisation
            exits early.  Set to ``0`` (default) to disable.
            Only honoured in the single-restart (sequential)
            path; when ``n_restarts > 1`` the parallel
            vmap+scan path always runs the full ``n_steps``.
        early_stop_min_delta (float): Minimum decrease in loss
            that counts as an improvement for the early-stopping
            patience counter.  Defaults to ``0.0`` (any strict
            improvement resets the counter).
        plot (bool): If ``True``, save a loss-landscape figure after
            Phase 0 and a loss-curve figure after Phase 1 to
            ``file_dir``.  Requires ``matplotlib`` to be installed.
            Defaults to ``False``.
    """
    self.envelope = envelope
    self.n_steps = n_steps
    self.n_samples = n_samples
    self.learning_rate = learning_rate
    self.warmup_ratio = warmup_ratio
    self.end_lr_ratio = end_lr_ratio
    self.log_interval = log_interval
    self.file_dir = (
        file_dir if file_dir else os.path.dirname(os.path.realpath(__file__))
    )
    self.t_target = t_target
    self.n_restarts = max(1, n_restarts)
    self.restart_noise_scale = restart_noise_scale
    self.grad_clip = grad_clip
    self.random_key = jax.random.PRNGKey(random_seed)
    self.scan_steps = scan_steps
    self.scan_grid_size = scan_grid_size
    self.scan_ranges = scan_ranges

    # Determine log-scale param indices
    envelope_info = PulseEnvelope.get(envelope)
    n_env = envelope_info["n_envelope_params"]
    if log_scale_params is not None:
        self.log_scale_params = log_scale_params
    elif n_env >= 2:
        # Default: amplitude (index 0) and evolution time (last)
        self.log_scale_params = [0, -1]
    else:
        self.log_scale_params = []

    # Mask cache used by ``_to_log_space``/``_from_log_space``;
    # rebuilt lazily because the mask length depends on the size of
    # the param vector being converted (per-gate vs joint).
    self._log_mask_cache: Dict[int, jnp.ndarray] = {}

    self.early_stop_patience = max(0, int(early_stop_patience))
    self.early_stop_min_delta = float(early_stop_min_delta)

    self.plot = plot

    log.info(
        f"Training parameters: {self.n_steps} steps, "
        f"{self.n_samples} samples, {self.learning_rate} learning rate"
    )
    log.info(
        f"LR schedule: warmup_ratio={self.warmup_ratio}, "
        f"end_lr_ratio={self.end_lr_ratio}"
    )

    log.info(f"Envelope: {self.envelope}")
    log.info(f"Target evolution time: {self.t_target}")
    log.info(
        f"Restarts: {self.n_restarts}, noise_scale={self.restart_noise_scale}, "
        f"grad_clip={self.grad_clip}"
    )
    if PulseInformation.get_rwa():
        log.info("Using RWA. Rotating frame is ignored.")
    else:
        log.info(f"Using no RWA and {PulseInformation.get_frame()} frame.")

    if self.early_stop_patience > 0:
        log.info(
            f"Early stopping: patience={self.early_stop_patience}, "
            f"min_delta={self.early_stop_min_delta:g}"
        )
    log.info(
        f"Grid scan: scan_steps={self.scan_steps}, "
        f"scan_grid_size={self.scan_grid_size}, "
        f"log_scale_params={self.log_scale_params}"
    )
    log.info(f"Using cost function(s) {cost_fns}")

    # Validate each entry against the registry
    summed_weights = 0
    for name, _weight in cost_fns:
        CostFnRegistry.get(name)  # raises ValueError if unknown
        summed_weights += sum(_weight) if isinstance(_weight, tuple) else _weight
    assert jnp.isclose(summed_weights, 1.0, rtol=1e-8), (
        f"Cost function weights must sum to 1. Got {summed_weights}"
    )

    self.cost_fns = cost_fns

    # Configure the pulse system with the selected envelope
    PulseInformation.set_envelope(self.envelope)

create_CPhase() #

Create pulse and target circuits for the CPhase gate.

Source code in qml_essentials/qoc.py
def create_CPhase(self):
    """Create pulse and target circuits for the CPhase gate."""

    def pulse_circuit(w, pulse_params):
        op.H(wires=0)
        op.H(wires=1)
        Gates.CPhase(w, wires=[0, 1], pulse_params=pulse_params, gate_mode="pulse")

    def target_circuit(w):
        op.H(wires=0)
        op.H(wires=1)
        op.ControlledPhaseShift(w, wires=[0, 1])

    return pulse_circuit, target_circuit

optimize(wires) #

Decorator factory that optimises pulse parameters for a gate.

Usage::

opt = qoc.optimize(wires=1)
best_params, loss_history = opt(qoc.create_RX)()

Parameters:

Name Type Description Default
wires int

Number of qubits the gate acts on.

required

Returns:

Type Description
Callable

A decorator that accepts a circuit-factory function and

Callable

returns a callable ``(init_pulse_params=None) ->

Callable

(best_params, loss_history)``.

Source code in qml_essentials/qoc.py
def optimize(self, wires: int) -> Callable:
    """Decorator factory that optimises pulse parameters for a gate.

    Usage::

        opt = qoc.optimize(wires=1)
        best_params, loss_history = opt(qoc.create_RX)()

    Args:
        wires: Number of qubits the gate acts on.

    Returns:
        A decorator that accepts a circuit-factory function and
        returns a callable ``(init_pulse_params=None) ->
        (best_params, loss_history)``.
    """

    def decorator(create_circuits):
        def wrapper(init_pulse_params: jnp.ndarray = None):
            """
            Optimise pulse parameters for a quantum gate using a
            multi-phase strategy:

            Stage 0 - Grid scan (if ``scan_steps > 0``):
                Evaluate a coarse grid of parameter candidates using
                the same weighted cost as Stage 1.  Each candidate
                is refined with a few fast gradient steps.  The
                best candidate becomes the starting point for
                Stage 1, unless the user-supplied init_pulse_params
                are already better.

            Stage 1 - Multi-restart gradient optimisation:
                Run ``n_restarts`` independent Adam optimisation runs
                with the full cost function.  The first restart uses
                the best point found so far; subsequent restarts add
                random perturbations.  Parameters at indices in
                ``log_scale_params`` are optimised in log-space to
                handle order-of-magnitude differences in scale.

            Args:
                init_pulse_params (array): Initial pulse parameters.
                    If ``None``, uses the envelope defaults from
                    :class:`PulseInformation`.

            Returns:
                tuple: ``(best_params, loss_history)`` from the best
                    restart.
            """
            pulse_circuit, target_circuit = create_circuits()

            # Build a second pair that prepends a Hadamard on every
            # wire so the cost is also evaluated from the
            # ``|+⟩^⊗n`` initial state.  Probing two non-collinear
            # initial states exposes rotation-axis tilt to the
            # optimiser: an RX/RY pulse with a residual Z component
            # is partly degenerate from ``|0⟩`` alone but produces
            # a clearly distinguishable trajectory from ``|+⟩``.
            # Both circuits get the same preparation so the target
            # remains exact.
            def _with_plus_prep(circuit_fn):
                def prepared(*args, **kwargs):
                    for q in range(wires):
                        op.H(wires=q)
                    circuit_fn(*args, **kwargs)

                prepared.__name__ = f"plus_{circuit_fn.__name__}"
                return prepared

            pulse_circuit_plus = _with_plus_prep(pulse_circuit)
            target_circuit_plus = _with_plus_prep(target_circuit)

            pulse_scripts = [
                js.Script(pulse_circuit, n_qubits=wires),
                js.Script(pulse_circuit_plus, n_qubits=wires),
            ]
            target_scripts = [
                js.Script(target_circuit, n_qubits=wires),
                js.Script(target_circuit_plus, n_qubits=wires),
            ]

            d_basis = 2**wires
            pulse_basis_scripts = [
                js.Script(_with_basis_prep(pulse_circuit, k, wires), n_qubits=wires)
                for k in range(d_basis)
            ]
            target_basis_scripts = [
                js.Script(
                    _with_basis_prep(target_circuit, k, wires), n_qubits=wires
                )
                for k in range(d_basis)
            ]

            gate_name = create_circuits.__name__.split("_")[1]

            if init_pulse_params is None:
                init_pulse_params = PulseInformation.gate_by_name(gate_name).params
            log.debug(
                f"Initial pulse parameters for {gate_name}: {init_pulse_params}"
            )

            all_ckwargs = {
                "pulse_scripts": pulse_scripts,
                "target_scripts": target_scripts,
                "pulse_basis_scripts": pulse_basis_scripts,
                "target_basis_scripts": target_basis_scripts,
                "envelope": self.envelope,
                "n_samples": self.n_samples,
                "n_qubits": wires,
                "t_target": self.t_target,
            }

            def _build_cost(name, weight):
                """Build a Cost from a registry entry, filtering ckwargs."""
                meta = CostFnRegistry.get(name)
                return Cost(
                    cost=meta["fn"],
                    weight=weight,
                    ckwargs={
                        k: v
                        for k, v in all_ckwargs.items()
                        if k in meta["ckwargs_keys"]
                    },
                )

            total_costs = None
            for name, weight in self.cost_fns:
                total_costs = _build_cost(name, weight) + total_costs

            best_scan_params, scan_data = self.stage_0_opt(
                init_pulse_params,
                total_costs,
            )

            global_best_params, global_best_history, global_best_loss = (
                self.stage_1_opt(
                    best_scan_params,
                    total_costs,
                )
            )
            self.save_results(
                gate=gate_name,
                fidelity=1 - global_best_loss.item(),
                pulse_params=global_best_params,
            )

            if self.plot:
                if scan_data is not None:
                    grid_axes, landscape_items = scan_data
                    self.plot_loss_landscape(gate_name, grid_axes, landscape_items)
                self.plot_loss_curve(gate_name, global_best_history)

            return global_best_params, global_best_history

        return wrapper

    return decorator

optimize_all(sel_gates, make_log) #

Optimise all selected gates and optionally write a log CSV.

Parameters:

Name Type Description Default
sel_gates str

Comma-separated gate names or "all".

required
make_log bool

If True, write per-gate loss histories to qml_essentials/qoc_logs.csv.

required
Source code in qml_essentials/qoc.py
def optimize_all(self, sel_gates: str, make_log: bool) -> None:
    """Optimise all selected gates and optionally write a log CSV.

    Args:
        sel_gates: Comma-separated gate names or ``"all"``.
        make_log: If ``True``, write per-gate loss histories to
            ``qml_essentials/qoc_logs.csv``.
    """
    # Joint mode (Round 3) is now implemented in :meth:`optimize_joint`.
    # The `--joint` CLI flag selects it instead of this per-gate loop.
    log_history: Dict[str, list] = {}

    for gate in self.GATES_1Q + self.GATES_2Q:
        if gate in sel_gates or "all" in sel_gates:
            n_wires = 1 if gate in self.GATES_1Q else 2
            opt = self.optimize(wires=n_wires)
            gate_factory = getattr(self, f"create_{gate}")
            log.info(f"Optimizing {gate} gate...")
            optimized_pulse_params, loss_history = opt(gate_factory)()
            log.info(f"Optimized parameters for {gate}: {optimized_pulse_params}")
            best_fid = 1 - min(float(loss) for loss in loss_history)
            log.info(f"Best achieved fidelity: {best_fid * 100:.5f}%")
            log_history[gate] = log_history.get(gate, []) + loss_history

    if make_log:
        # write log history to file
        with open("qml_essentials/qoc_logs.csv", "w") as f:
            writer = csv.writer(f)
            writer.writerow(log_history.keys())
            writer.writerows(zip(*log_history.values()))

optimize_joint(target_gates=None, leaf_names=None, weights=None) #

Joint composite-aware optimisation of leaf pulse parameters.

Optimises a single shared parameter vector theta (containing the concatenated leaf params for leaf_names) against a weighted sum of unitary-cost terms over target_gates. Composite gates back-propagate into the shared leaves; leaf terms keep the standalone fidelity acceptable. CZ is omitted from the default targets because the PulseGates.CZ implementation is a static diagonal-Hamiltonian evolution (H_CZ = π·|11⟩⟨11|, t=1) that is structurally exact and unaffected by any leaf re-tuning.

Parameters:

Name Type Description Default
target_gates Optional[List[str]]

Gates whose unitary cost contributes to the joint objective. Defaults to :pyattr:JOINT_TARGETS_DEFAULT (RX, RY, RZ, H, CX, CRX, CRY, CRZ).

None
leaf_names Optional[List[str]]

Leaf gates whose parameters are jointly optimised. Defaults to :pyattr:JOINT_LEAVES_DEFAULT (RX, RY, RZ, CZ).

None
weights Optional[Dict[str, float]]

Optional mapping gate_name → weight. Merged on top of :pyattr:JOINT_WEIGHTS_DEFAULT (composites up-weighted; leaves down-weighted). All weights are normalised inside the cost.

None

Returns:

Name Type Description
ndarray

(best_theta, leaf_slices, loss_history). Per-leaf

Dict[str, slice]

results are also written to qoc_results_<envelope>.csv

via list

meth:save_results.

Source code in qml_essentials/qoc.py
def optimize_joint(
    self,
    target_gates: Optional[List[str]] = None,
    leaf_names: Optional[List[str]] = None,
    weights: Optional[Dict[str, float]] = None,
) -> Tuple[jnp.ndarray, Dict[str, slice], list]:
    """Joint composite-aware optimisation of leaf pulse parameters.

    Optimises a single shared parameter vector ``theta`` (containing
    the concatenated leaf params for ``leaf_names``) against a
    weighted sum of unitary-cost terms over ``target_gates``.
    Composite gates back-propagate into the shared leaves; leaf
    terms keep the standalone fidelity acceptable.  CZ is omitted
    from the default targets because the ``PulseGates.CZ``
    implementation is a static diagonal-Hamiltonian evolution
    (``H_CZ = π·|11⟩⟨11|``, t=1) that is structurally exact and
    unaffected by any leaf re-tuning.

    Args:
        target_gates: Gates whose unitary cost contributes to the
            joint objective.  Defaults to
            :pyattr:`JOINT_TARGETS_DEFAULT` (RX, RY, RZ, H, CX,
            CRX, CRY, CRZ).
        leaf_names: Leaf gates whose parameters are jointly
            optimised.  Defaults to :pyattr:`JOINT_LEAVES_DEFAULT`
            (RX, RY, RZ, CZ).
        weights: Optional mapping ``gate_name → weight``.  Merged
            on top of :pyattr:`JOINT_WEIGHTS_DEFAULT` (composites
            up-weighted; leaves down-weighted).  All weights are
            normalised inside the cost.

    Returns:
        ``(best_theta, leaf_slices, loss_history)``.  Per-leaf
        results are also written to ``qoc_results_<envelope>.csv``
        via :meth:`save_results`.
    """
    if target_gates:
        target_gates = list(target_gates)
    else:
        target_gates = list(self.JOINT_TARGETS_DEFAULT)

    if leaf_names:
        leaf_names = list(leaf_names)
    else:
        leaf_names = list(self.JOINT_LEAVES_DEFAULT)

    # Merge user-provided weights on top of class defaults so callers
    # can override only the gates they care about.
    merged_weights: Dict[str, float] = dict(self.JOINT_WEIGHTS_DEFAULT)
    if weights:
        merged_weights.update({k: float(v) for k, v in weights.items()})
    weights = merged_weights

    log.info(f"Joint optimisation: leaves={leaf_names}, targets={target_gates}")

    init_theta, leaf_slices, joint_log_idx = self._build_joint_layout(
        tuple(leaf_names)
    )
    log.info(
        f"  Joint theta size: {init_theta.shape[0]}; "
        f"log-scale indices: {joint_log_idx}"
    )

    # Build per-gate specs (assembler + basis-prep scripts).
    gate_specs: List[dict] = []
    for gname in target_gates:
        pp_obj = PulseInformation.gate_by_name(gname)
        if pp_obj is None:
            log.warning(f"  Skipping unknown gate {gname!r}.")
            continue
        n_wires = 1 if gname in self.GATES_1Q else 2
        d_basis = 2**n_wires
        pulse_circuit, target_circuit = self._create_joint_pair_for(gname)

        pulse_basis_scripts = [
            js.Script(_with_basis_prep(pulse_circuit, k, n_wires), n_qubits=n_wires)
            for k in range(d_basis)
        ]
        target_basis_scripts = [
            js.Script(
                _with_basis_prep(target_circuit, k, n_wires), n_qubits=n_wires
            )
            for k in range(d_basis)
        ]

        # Closure capturing pp_obj + leaf_slices.  Defined here so
        # each spec carries its own assembler.
        def _make_assembler(pp_obj=pp_obj):
            def assemble(theta):
                return QOC._assemble_for_gate(theta, pp_obj, leaf_slices)

            return assemble

        gate_specs.append(
            {
                "name": gname,
                "n_qubits": n_wires,
                "weight": float(weights.get(gname, 1.0)),
                "assembler": _make_assembler(),
                "pulse_basis_scripts": pulse_basis_scripts,
                "target_basis_scripts": target_basis_scripts,
            }
        )
        log.info(
            f"  Built spec for {gname}: n_qubits={n_wires}, "
            f"weight={gate_specs[-1]['weight']}"
        )

    # Build the joint cost as a Cost wrapper (so weight-tuple
    # collapsing into a scalar is shared with the per-gate path).
    # We use the same (process_loss, phase_loss) two-component
    # weighting as the standalone unitary cost — keeps the relative
    # importance of fidelity vs phase consistent.
    ((_, weight_tuple),) = (
        ((n, w) for n, w in self.cost_fns if n == "unitary")
        if any(n == "unitary" for n, _ in self.cost_fns)
        else ((None, (0.5, 0.5)),)
    )
    joint_cost = Cost(
        cost=joint_unitary_cost_fn,
        weight=weight_tuple,
        ckwargs={
            "gate_specs": gate_specs,
            "n_samples": self.n_samples,
        },
    )

    # Temporarily override log_scale_params to point at joint
    # vector indices (Stage 0 grid building + Stage 1 log-space
    # reparam both consult ``self.log_scale_params``).  Invalidate
    # the mask cache on either side of the swap so the joint
    # vector picks up the joint indices and per-gate runs revert
    # cleanly afterwards.
    prev_log_scale = self.log_scale_params
    self.log_scale_params = joint_log_idx
    self._log_mask_cache.clear()
    try:
        best_scan_theta = self._joint_stage_0_coord_descent(
            init_theta, leaf_slices, joint_cost
        )

        global_best_theta, global_best_history, global_best_loss = self.stage_1_opt(
            best_scan_theta, joint_cost
        )
    finally:
        self.log_scale_params = prev_log_scale
        self._log_mask_cache.clear()

    log.info(f"Joint optimisation done. final loss={float(global_best_loss):.6e}")

    # Save per-leaf results to the CSV (one row per leaf).  The
    # fidelity column carries the *joint* fidelity; downstream code
    # that reads the CSV (or the user copy-pasting into pulses.py)
    # can use it as a coarse quality signal.
    joint_fid = float(1.0 - global_best_loss)
    for leaf_name, sl in leaf_slices.items():
        leaf_params = global_best_theta[sl]
        self.save_results(
            gate=leaf_name,
            fidelity=joint_fid,
            pulse_params=leaf_params,
        )

    # Update PulseInformation in-place so the new defaults are
    # active in this Python process (handy for diagnostic scripts
    # that import QOC and then evaluate the new gates).
    for leaf_name, sl in leaf_slices.items():
        pp = PulseInformation.gate_by_name(leaf_name)
        pp.params = global_best_theta[sl]

    return global_best_theta, leaf_slices, global_best_history

plot_loss_curve(gate_name, loss_history) #

Save a training-loss curve figure for the Phase-1 optimisation.

Shows loss vs. optimisation step on a log y-scale with a dashed horizontal line at the minimum achieved loss.

The figure is saved to {file_dir}/{gate_name}_loss_curve.png.

Parameters:

Name Type Description Default
gate_name str

Name of the gate being optimised (e.g. "RX").

required
loss_history list

Sequence of loss values, one per step (including the initial loss at index 0).

required
Source code in qml_essentials/qoc.py
def plot_loss_curve(
    self,
    gate_name: str,
    loss_history: list,
) -> None:
    """Save a training-loss curve figure for the Phase-1 optimisation.

    Shows loss vs. optimisation step on a log y-scale with a dashed
    horizontal line at the minimum achieved loss.

    The figure is saved to ``{file_dir}/{gate_name}_loss_curve.png``.

    Args:
        gate_name: Name of the gate being optimised (e.g. ``"RX"``).
        loss_history: Sequence of loss values, one per step (including
            the initial loss at index 0).
    """
    import matplotlib.pyplot as plt  # lazy — matplotlib is dev-only

    if not loss_history:
        log.warning("plot_loss_curve: empty loss history, skipping.")
        return

    os.makedirs(self.file_dir, exist_ok=True)
    losses = [float(v) for v in loss_history]
    best = min(losses)

    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(losses, linewidth=1.2, label="Loss")
    ax.axhline(
        best, color="red", linestyle="--", linewidth=1.0, label=f"Best: {best:.3e}"
    )
    ax.set_xlabel("Step")
    ax.set_ylabel("Loss")
    ax.set_yscale("log")
    ax.set_title(f"Training Loss (Phase 1) — {gate_name}")
    ax.legend()
    fig.tight_layout()
    path = os.path.join(self.file_dir, f"{gate_name}_loss_curve.png")
    fig.savefig(path, dpi=150)
    plt.close(fig)
    log.info(f"Loss curve saved to {path}")

plot_loss_landscape(gate_name, grid_axes, landscape_data) #

Save a loss-landscape figure for the Phase-0 grid scan.

The visualisation adapts to the number of pulse parameters:

  • 1 parameter: line/scatter plot (param value vs. loss).
  • 2 parameters: 2-D heatmap (param₀ × param₁, colour = loss).
  • ≥ 3 parameters: horizontal scatter sorted by ascending loss with the best candidate highlighted.

The figure is saved to {file_dir}/{gate_name}_loss_landscape.png.

Parameters:

Name Type Description Default
gate_name str

Name of the gate being optimised (e.g. "RX").

required
grid_axes List[ndarray]

Per-parameter 1-D arrays that span the scan grid.

required
landscape_data list

List of (candidate_index, params, loss) tuples for every successful scan candidate.

required
Source code in qml_essentials/qoc.py
def plot_loss_landscape(
    self,
    gate_name: str,
    grid_axes: List[jnp.ndarray],
    landscape_data: list,
) -> None:
    """Save a loss-landscape figure for the Phase-0 grid scan.

    The visualisation adapts to the number of pulse parameters:

    - **1 parameter**: line/scatter plot (param value vs. loss).
    - **2 parameters**: 2-D heatmap (param₀ × param₁, colour = loss).
    - **≥ 3 parameters**: horizontal scatter sorted by ascending loss
      with the best candidate highlighted.

    The figure is saved to ``{file_dir}/{gate_name}_loss_landscape.png``.

    Args:
        gate_name: Name of the gate being optimised (e.g. ``"RX"``).
        grid_axes: Per-parameter 1-D arrays that span the scan grid.
        landscape_data: List of ``(candidate_index, params, loss)``
            tuples for every successful scan candidate.
    """
    import matplotlib.pyplot as plt  # lazy — matplotlib is dev-only

    if not landscape_data:
        log.warning("plot_loss_landscape: no landscape data to plot, skipping.")
        return

    os.makedirs(self.file_dir, exist_ok=True)
    n_params = len(grid_axes)
    indices, _params_list, losses = zip(*landscape_data)
    losses_arr = np.array(losses, dtype=float)

    fig, ax = plt.subplots(figsize=(8, 5))

    if n_params == 1:
        x = np.array([float(grid_axes[0][i]) for i in indices])
        sc = ax.scatter(
            x, losses_arr, c=losses_arr, cmap="viridis_r", s=60, zorder=3
        )
        fig.colorbar(sc, ax=ax, label="Loss")
        best_i = int(np.argmin(losses_arr))
        ax.scatter(
            x[best_i],
            losses_arr[best_i],
            marker="*",
            s=200,
            color="red",
            zorder=4,
            label="best",
        )
        ax.set_xlabel("Parameter value")
        ax.set_xscale("log")
        ax.set_yscale("log")
        ax.legend()

    elif n_params == 2:
        n = self.scan_grid_size
        loss_grid = np.full((n, n), np.nan)
        for ci, _, loss in landscape_data:
            row = ci // n
            col = ci % n
            loss_grid[row, col] = loss
        masked = np.ma.masked_invalid(loss_grid)
        cmap = plt.cm.viridis_r.copy()
        cmap.set_bad(color="lightgrey")
        im = ax.imshow(
            masked,
            origin="lower",
            cmap=cmap,
            aspect="auto",
            extent=[
                float(grid_axes[1][0]),
                float(grid_axes[1][-1]),
                float(grid_axes[0][0]),
                float(grid_axes[0][-1]),
            ],
        )
        fig.colorbar(im, ax=ax, label="Loss")
        ax.set_xlabel("Parameter 1")
        ax.set_ylabel("Parameter 0")

    else:  # n_params >= 3: sorted scatter
        order = np.argsort(losses_arr)
        sorted_losses = losses_arr[order]
        sorted_indices = np.array(indices)[order]  # original trial numbers
        ranks = np.arange(len(sorted_losses))
        sc = ax.scatter(
            sorted_losses,
            ranks,
            c=sorted_indices,
            cmap="plasma",
            s=40,
            zorder=3,
        )
        fig.colorbar(sc, ax=ax, label="Trial number")
        ax.scatter(
            sorted_losses[0],
            ranks[0],
            marker="*",
            s=200,
            color="red",
            zorder=4,
            label="best",
        )
        ax.set_xlabel("Loss")
        ax.set_ylabel("Candidate rank (0 = best)")
        ax.set_xscale("log")
        ax.legend()

    ax.set_title(f"Loss Landscape (Phase 0) — {gate_name}")
    fig.tight_layout()
    path = os.path.join(self.file_dir, f"{gate_name}_loss_landscape.png")
    fig.savefig(path, dpi=150)
    plt.close(fig)
    log.info(f"Loss landscape saved to {path}")

save_results(gate, fidelity, pulse_params) #

Save optimised pulse parameters and fidelity for a gate to CSV.

If the gate already exists in the file, its entry is overwritten regardless of whether the new fidelity is higher. A warning is logged when the existing fidelity was better.

Parameters:

Name Type Description Default
gate str

Name of the gate (e.g. "RX").

required
fidelity float

Achieved fidelity of the optimised pulse.

required
pulse_params ndarray

Optimised pulse parameters for the gate.

required
Source code in qml_essentials/qoc.py
def save_results(self, gate: str, fidelity: float, pulse_params) -> None:
    """Save optimised pulse parameters and fidelity for a gate to CSV.

    If the gate already exists in the file, its entry is overwritten
    regardless of whether the new fidelity is higher.  A warning is
    logged when the existing fidelity was better.

    Args:
        gate: Name of the gate (e.g. ``"RX"``).
        fidelity: Achieved fidelity of the optimised pulse.
        pulse_params (jnp.ndarray): Optimised pulse parameters for the gate.
    """
    if self.file_dir is not None:
        os.makedirs(self.file_dir, exist_ok=True)
        filename = os.path.join(self.file_dir, f"qoc_results_{self.envelope}.csv")

        reader = None
        if os.path.isfile(filename):
            with open(filename, mode="r", newline="") as f:
                reader = csv.reader(f.readlines())

        entry = [gate] + [fidelity] + list(map(float, pulse_params))

        with open(filename, mode="w", newline="") as f:
            writer = csv.writer(f)
            match = False
            if reader is not None:
                for row in reader:
                    # gate already exists
                    if row[0] == gate:
                        if fidelity <= float(row[1]):
                            log.warning(
                                f"Pulse parameters for {gate} already exist with "
                                f"higher fidelity ({row[1]} >= {fidelity})"
                            )
                        writer.writerow(entry)
                        match = True
                    # any other gate
                    else:
                        writer.writerow(row)
            # gate does not exist
            if not match:
                writer.writerow(entry)

stage_0_opt(init_pulse_params, total_cost) #

Run the coarse grid-scan phase (Stage 0).

Evaluates a Cartesian grid of parameter candidates using the full weighted cost (fidelity + phase, plus any other registered terms) — the same objective as Stage 1. Each candidate is refined with a few fast gradient steps. Returns the best-found parameters.

Sharing the objective with Stage 1 prevents the grid scan from landing in a basin that has high fidelity but a biased phase which Adam then has to migrate out of (the previous fidelity-only scan caused exactly this failure mode for RX/RY, whose phase residuals compounded in the CRX decomposition).

Robustness: candidates that produce a non-finite loss (e.g. when the underlying pulse drives the integrator into a NaN — typical for very narrow DRAG envelopes) are skipped with a warning. For the duration of the scan, :class:qml_essentials.evolution.Evolution is switched into throw=False mode so a single bad candidate cannot abort the loop with MaxStepsReached; the previous defaults are restored on exit.

Parameters:

Name Type Description Default
init_pulse_params ndarray

Initial pulse parameters to compare against.

required
total_cost Callable

Combined cost callable (same as Stage 1).

required

Returns:

Type Description
ndarray

Tuple of:

Optional[Tuple[List[ndarray], list]]
  • Best pulse parameters found during the scan.
Tuple[ndarray, Optional[Tuple[List[ndarray], list]]]
  • (grid_axes, landscape_data) if the grid scan ran, else None. landscape_data is a list of (candidate_index, original_params, loss) tuples for every successful scan candidate.
Source code in qml_essentials/qoc.py
def stage_0_opt(
    self, init_pulse_params: jnp.ndarray, total_cost: Callable
) -> Tuple[jnp.ndarray, Optional[Tuple[List[jnp.ndarray], list]]]:
    """Run the coarse grid-scan phase (Stage 0).

    Evaluates a Cartesian grid of parameter candidates using the
    **full weighted cost** (fidelity + phase, plus any other
    registered terms) — the same objective as Stage 1.  Each
    candidate is refined with a few fast gradient steps.  Returns
    the best-found parameters.

    Sharing the objective with Stage 1 prevents the grid scan from
    landing in a basin that has high fidelity but a biased phase
    which Adam then has to migrate out of (the previous
    fidelity-only scan caused exactly this failure mode for RX/RY,
    whose phase residuals compounded in the CRX decomposition).

    Robustness: candidates that produce a non-finite loss (e.g. when
    the underlying pulse drives the integrator into a NaN — typical
    for very narrow DRAG envelopes) are skipped with a warning.  For
    the duration of the scan, :class:`qml_essentials.evolution.Evolution` is
    switched into ``throw=False`` mode so a single bad candidate
    cannot abort the loop with ``MaxStepsReached``; the previous
    defaults are restored on exit.

    Args:
        init_pulse_params: Initial pulse parameters to compare against.
        total_cost: Combined cost callable (same as Stage 1).

    Returns:
        Tuple of:
        - Best pulse parameters found during the scan.
        - ``(grid_axes, landscape_data)`` if the grid scan ran, else
          ``None``.  ``landscape_data`` is a list of
          ``(candidate_index, original_params, loss)`` tuples for
          every successful scan candidate.
    """

    def total_cost_log(log_params, *args):
        return total_cost(self._from_log_space(log_params), *args)

    best_scan_params = init_pulse_params
    best_scan_loss = _safe_eval(total_cost, init_pulse_params)
    if not jnp.isfinite(best_scan_loss):
        log.warning(
            "Stage 0: initial pulse parameters produced a non-finite "
            "loss; falling back to a placeholder loss of +inf."
        )

    landscape_data: list = []
    axes_out: Optional[List[jnp.ndarray]] = None

    if self.scan_steps > 0:
        log.info(
            f"Stage 0: Grid scan with {self.scan_grid_size}^"
            f"{len(init_pulse_params)} candidates, "
            f"{self.scan_steps} steps each"
        )

        grid, axes_out = self._build_scan_grid(
            len(init_pulse_params),
            init_pulse_params=init_pulse_params,
        )
        log.info(f"  Total candidates: {len(grid)}")

        # Use a fast Adam for the scan phase.  The aggressive 5×
        # multiplier originally used here tended to push refined
        # candidates *out* of good basins; 2× keeps the refinement
        # localised.  Always-evaluate-the-raw-candidate below
        # additionally guards against this.
        scan_optimizer = optax.chain(
            optax.clip_by_global_norm(
                self.grad_clip if self.grad_clip > 0 else 1.0
            ),
            optax.adam(self.learning_rate * 2),
        )

        @jax.jit
        def refine_candidate(log_candidate):
            """Run ``self.scan_steps`` Adam steps on a single candidate.

            Fused into a single ``jax.lax.scan`` so the whole
            refinement is one XLA program — no per-step host
            syncs, no Python-loop dispatch.  Returns the final
            log-params and a scalar bool ``failed`` flag (set if
            any intermediate update produced a non-finite value).
            """

            opt_state0 = scan_optimizer.init(log_candidate)

            def body(carry, _):
                log_p, opt_state, failed = carry
                loss, grads = jax.value_and_grad(total_cost_log)(log_p)
                updates, opt_state = scan_optimizer.update(grads, opt_state, log_p)
                new_log_p = optax.apply_updates(log_p, updates)
                new_failed = failed | (~jnp.all(jnp.isfinite(new_log_p)))
                # Freeze on failure so subsequent steps cannot
                # propagate NaNs further.
                new_log_p = jnp.where(new_failed, log_p, new_log_p)
                return (new_log_p, opt_state, new_failed), loss

            (final_log_p, _, failed), _ = jax.lax.scan(
                body,
                (log_candidate, opt_state0, jnp.bool_(False)),
                None,
                length=self.scan_steps,
            )
            return final_log_p, failed

        # Switch the underlying ODE solver to non-throwing mode for
        # the duration of the scan so candidates that exceed the step
        # budget produce NaN unitaries (and therefore +inf losses)
        # rather than aborting the whole grid loop.
        prev_solver_defaults = js.Evolution.set_solver_defaults(throw=False)
        n_skipped = 0
        n_raw_better = 0
        try:
            for ci, candidate in enumerate(grid):
                log_candidate = self._to_log_space(candidate)

                # Evaluate the raw (unrefined) candidate so an
                # over-aggressive refinement step cannot discard
                # an already-good grid point.
                raw_loss = _safe_eval(total_cost, candidate)

                try:
                    log_p, failed_flag = refine_candidate(log_candidate)
                except Exception as exc:  # pragma: no cover - defensive
                    log.debug(
                        f"  Candidate {ci + 1}/{len(grid)} "
                        f"raised during refinement: {exc}; skipping."
                    )
                    physical_p = candidate
                    loss = raw_loss
                else:
                    if bool(failed_flag):
                        physical_p = candidate
                        loss = raw_loss
                    else:
                        physical_p = self._from_log_space(log_p)
                        if not jnp.all(jnp.isfinite(physical_p)):
                            physical_p = candidate
                            loss = raw_loss
                        else:
                            loss = _safe_eval(total_cost, physical_p)

                # Keep the better of (raw, refined) for this candidate.
                if jnp.isfinite(raw_loss) and (
                    not jnp.isfinite(loss) or raw_loss < loss
                ):
                    physical_p = candidate
                    loss = raw_loss
                    n_raw_better += 1

                if not jnp.isfinite(loss):
                    n_skipped += 1
                    continue

                landscape_data.append((ci, candidate, float(loss)))

                if loss < best_scan_loss:
                    best_scan_loss = loss
                    best_scan_params = physical_p
                    log.info(
                        f"  Candidate {ci + 1}/{len(grid)}: "
                        f"loss={float(loss):.6e} improved with "
                        f"params={physical_p}"
                    )
        finally:
            # Always restore the previous solver defaults so other
            # callers (including Stage 1) are unaffected.
            if prev_solver_defaults:
                js.Evolution.set_solver_defaults(**prev_solver_defaults)

        if n_skipped:
            log.warning(
                f"Stage 0: skipped {n_skipped}/{len(grid)} candidates "
                f"due to solver failure or non-finite loss "
                f"(typical for very narrow / very large-amplitude "
                f"DRAG pulses)."
            )
        if n_raw_better:
            log.info(
                f"Stage 0: {n_raw_better}/{len(grid)} candidates "
                f"were better unrefined than after the {self.scan_steps}-"
                f"step refinement; raw values were kept."
            )

        log.info(
            f"Stage 0 complete. Best loss: "
            f"{float(best_scan_loss):.6e}, "
            f"params: {best_scan_params}"
        )

    scan_data = (axes_out, landscape_data) if self.scan_steps > 0 else None
    return best_scan_params, scan_data

stage_1_opt(best_scan_params, total_costs) #

Run multi-restart gradient optimisation (Stage 1).

Performs n_restarts independent AdamW runs with the full (weighted) cost function. The first restart uses best_scan_params directly; subsequent restarts add random perturbations. Parameters specified in log_scale_params are optimised in log-space.

When n_restarts == 1 we keep the original single-restart Python loop (it preserves per-step log.info granularity and avoids the vmap/scan compilation overhead). When n_restarts > 1 we vmap the optimiser over restarts and run the inner step loop with :func:jax.lax.scan, fusing all n_restarts × n_steps steps into a single XLA program.

Parameters:

Name Type Description Default
best_scan_params ndarray

Starting parameters (typically from Stage 0).

required
total_costs Callable

Combined cost callable.

required

Returns:

Type Description
ndarray

Tuple of (best_params, loss_history, best_loss) from the

list

best restart.

Source code in qml_essentials/qoc.py
def stage_1_opt(
    self, best_scan_params: jnp.ndarray, total_costs: Callable
) -> Tuple[jnp.ndarray, list, jnp.ndarray]:
    """Run multi-restart gradient optimisation (Stage 1).

    Performs ``n_restarts`` independent AdamW runs with the full
    (weighted) cost function.  The first restart uses
    ``best_scan_params`` directly; subsequent restarts add random
    perturbations.  Parameters specified in ``log_scale_params`` are
    optimised in log-space.

    When ``n_restarts == 1`` we keep the original single-restart
    Python loop (it preserves per-step ``log.info`` granularity
    and avoids the vmap/scan compilation overhead).  When
    ``n_restarts > 1`` we ``vmap`` the optimiser over restarts and
    run the inner step loop with :func:`jax.lax.scan`, fusing all
    ``n_restarts × n_steps`` steps into a single XLA program.

    Args:
        best_scan_params: Starting parameters (typically from Stage 0).
        total_costs: Combined cost callable.

    Returns:
        Tuple of ``(best_params, loss_history, best_loss)`` from the
        best restart.
    """

    # Wrap the cost function with log-space reparameterisation
    def total_costs_log(log_params):
        return total_costs(self._from_log_space(log_params))

    # Build learning rate schedule
    warmup_steps = int(self.n_steps * self.warmup_ratio)
    end_value = self.learning_rate * self.end_lr_ratio

    if warmup_steps > 0 or self.end_lr_ratio < 1.0:
        schedule = optax.warmup_cosine_decay_schedule(
            init_value=(end_value if warmup_steps > 0 else self.learning_rate),
            peak_value=self.learning_rate,
            warmup_steps=warmup_steps,
            decay_steps=self.n_steps,
            end_value=end_value,
        )
    else:
        schedule = self.learning_rate

    optimizer = _build_optimizer(schedule, self.grad_clip)

    if self.n_restarts <= 1:
        return self._stage_1_sequential(
            best_scan_params, total_costs, total_costs_log, optimizer
        )
    return self._stage_1_parallel(
        best_scan_params, total_costs, total_costs_log, optimizer
    )

Cost Functions#

from qml_essentials.qoc import Cost

Weighted wrapper around a cost function.

Combines a cost callable with a scalar or tuple weight and optional constant keyword arguments. Multiple Cost instances can be composed via the + operator to build a combined objective.

Parameters:

Name Type Description Default
cost Callable

Callable (pulse_params, **ckwargs) -> scalar | tuple.

required
weight Union[float, Tuple]

Scalar or tuple of per-component weights.

required
ckwargs Optional[dict]

Constant keyword arguments injected into every call.

None
Source code in qml_essentials/qoc.py
class Cost:
    """Weighted wrapper around a cost function.

    Combines a cost callable with a scalar or tuple weight and optional
    constant keyword arguments.  Multiple ``Cost`` instances can be
    composed via the ``+`` operator to build a combined objective.

    Args:
        cost: Callable ``(pulse_params, **ckwargs) -> scalar | tuple``.
        weight: Scalar or tuple of per-component weights.
        ckwargs: Constant keyword arguments injected into every call.
    """

    def __init__(
        self,
        cost: Callable,
        weight: Union[float, Tuple],
        ckwargs: Optional[dict] = None,
    ):
        self.cost = cost
        self.weight = weight
        self.ckwargs = ckwargs if ckwargs is not None else {}

    def __call__(self, *args, **kwargs):
        """Evaluate the cost function with injected kwargs and apply weights."""
        cost = self.cost(*args, **kwargs, **self.ckwargs)
        if isinstance(self.weight, tuple):
            return jnp.array(
                [c * w for c, w in zip(cost, self.weight, strict=True)]
            ).sum()
        return cost * self.weight

    def __add__(self, other):
        """Compose two cost terms into a single callable that sums them."""
        if other is None:
            return lambda *args, **kwargs: self(*args, **kwargs)
        if callable(other):
            return lambda *args, **kwargs: (
                self(*args, **kwargs) + other(*args, **kwargs)
            )
        raise TypeError(f"Cannot add Cost and {type(other)}")

__add__(other) #

Compose two cost terms into a single callable that sums them.

Source code in qml_essentials/qoc.py
def __add__(self, other):
    """Compose two cost terms into a single callable that sums them."""
    if other is None:
        return lambda *args, **kwargs: self(*args, **kwargs)
    if callable(other):
        return lambda *args, **kwargs: (
            self(*args, **kwargs) + other(*args, **kwargs)
        )
    raise TypeError(f"Cannot add Cost and {type(other)}")

__call__(*args, **kwargs) #

Evaluate the cost function with injected kwargs and apply weights.

Source code in qml_essentials/qoc.py
def __call__(self, *args, **kwargs):
    """Evaluate the cost function with injected kwargs and apply weights."""
    cost = self.cost(*args, **kwargs, **self.ckwargs)
    if isinstance(self.weight, tuple):
        return jnp.array(
            [c * w for c, w in zip(cost, self.weight, strict=True)]
        ).sum()
    return cost * self.weight

Cost Function Registry#

from qml_essentials.qoc import CostFnRegistry

Registry of cost functions available for pulse optimisation.

Use :meth:register to add new cost functions at runtime and :meth:get / :meth:available to query them.

Source code in qml_essentials/qoc.py
class CostFnRegistry:
    """Registry of cost functions available for pulse optimisation.

    Use :meth:`register` to add new cost functions at runtime and
    :meth:`get` / :meth:`available` to query them.
    """

    _REGISTRY: Dict[str, dict] = {
        "fidelity": {
            "fn": fidelity_cost_fn,
            "default_weight": (0.5, 0.5),
            "ckwargs_keys": ["pulse_scripts", "target_scripts", "n_samples"],
        },
        "unitary": {
            "fn": unitary_cost_fn,
            "default_weight": (0.5, 0.5),
            "ckwargs_keys": [
                "pulse_basis_scripts",
                "target_basis_scripts",
                "n_samples",
                "n_qubits",
            ],
        },
        "pulse_width": {
            "fn": pulse_width_cost_fn,
            "default_weight": 1.0,
            "ckwargs_keys": ["envelope"],
        },
        "evolution_time": {
            "fn": evolution_time_cost_fn,
            "default_weight": 1.0,
            "ckwargs_keys": ["t_target"],
        },
        "spectral_density": {
            "fn": spectral_density_cost_fn,
            "default_weight": 1.0,
            "ckwargs_keys": ["envelope"],
        },
    }

    @classmethod
    def available(cls) -> List[str]:
        """Return the names of all registered cost functions."""
        return list(cls._REGISTRY.keys())

    @classmethod
    def get(cls, name: str) -> dict:
        """Look up cost-function metadata by name.

        Args:
            name: Registered cost function name.

        Returns:
            Metadata dict with keys ``fn``,
            ``default_weight``, ``ckwargs_keys``.

        Raises:
            ValueError: If name is not registered.
        """
        if name not in cls._REGISTRY:
            raise ValueError(
                f"Unknown cost function '{name}'. Available: {cls.available()}"
            )
        return cls._REGISTRY[name]

    @classmethod
    def parse_cost_arg(
        cls, spec: Union[str, Tuple]
    ) -> Tuple[str, Union[float, Tuple[float, ...]]]:
        """Parse a ``"name:w1,w2,..."`` CLI string into ``(name, weight)``.
        If a tuple is provided, it is returned directly.

        If the weight part is omitted the default weight from the registry
        is used.  A single-component weight is returned as a float;
        multi-component weights are returned as a tuple of floats.

        Args:
            spec: A string of the form ``"name"`` or ``"name:w1,w2,..."``.

        Returns:
            A tuple of ``(name, weight)``.

        Raises:
            ValueError: If the name is unknown or the number of weight
                components does not match the ones in ``default_weight``.
        """
        if isinstance(spec, tuple):
            return spec

        if ":" in spec:
            name, weight_str = spec.split(":", 1)
            parts = [float(x) for x in weight_str.split(",")]
            weight: Union[float, Tuple[float, ...]] = (
                parts[0] if len(parts) == 1 else tuple(parts)
            )
        else:
            name = spec
            weight = cls.get(name)["default_weight"]

        # Validate weight count
        got = len(weight) if isinstance(weight, tuple) else 1
        default_weight = cls.get(name)["default_weight"]
        expected = len(default_weight) if isinstance(default_weight, tuple) else 1

        if got != expected:
            raise ValueError(
                f"Cost function '{name}' expects {expected} weight(s), got {got}."
            )

        return name, weight

available() classmethod #

Return the names of all registered cost functions.

Source code in qml_essentials/qoc.py
@classmethod
def available(cls) -> List[str]:
    """Return the names of all registered cost functions."""
    return list(cls._REGISTRY.keys())

get(name) classmethod #

Look up cost-function metadata by name.

Parameters:

Name Type Description Default
name str

Registered cost function name.

required

Returns:

Type Description
dict

Metadata dict with keys fn,

dict

default_weight, ckwargs_keys.

Raises:

Type Description
ValueError

If name is not registered.

Source code in qml_essentials/qoc.py
@classmethod
def get(cls, name: str) -> dict:
    """Look up cost-function metadata by name.

    Args:
        name: Registered cost function name.

    Returns:
        Metadata dict with keys ``fn``,
        ``default_weight``, ``ckwargs_keys``.

    Raises:
        ValueError: If name is not registered.
    """
    if name not in cls._REGISTRY:
        raise ValueError(
            f"Unknown cost function '{name}'. Available: {cls.available()}"
        )
    return cls._REGISTRY[name]

parse_cost_arg(spec) classmethod #

Parse a "name:w1,w2,..." CLI string into (name, weight). If a tuple is provided, it is returned directly.

If the weight part is omitted the default weight from the registry is used. A single-component weight is returned as a float; multi-component weights are returned as a tuple of floats.

Parameters:

Name Type Description Default
spec Union[str, Tuple]

A string of the form "name" or "name:w1,w2,...".

required

Returns:

Type Description
Tuple[str, Union[float, Tuple[float, ...]]]

A tuple of (name, weight).

Raises:

Type Description
ValueError

If the name is unknown or the number of weight components does not match the ones in default_weight.

Source code in qml_essentials/qoc.py
@classmethod
def parse_cost_arg(
    cls, spec: Union[str, Tuple]
) -> Tuple[str, Union[float, Tuple[float, ...]]]:
    """Parse a ``"name:w1,w2,..."`` CLI string into ``(name, weight)``.
    If a tuple is provided, it is returned directly.

    If the weight part is omitted the default weight from the registry
    is used.  A single-component weight is returned as a float;
    multi-component weights are returned as a tuple of floats.

    Args:
        spec: A string of the form ``"name"`` or ``"name:w1,w2,..."``.

    Returns:
        A tuple of ``(name, weight)``.

    Raises:
        ValueError: If the name is unknown or the number of weight
            components does not match the ones in ``default_weight``.
    """
    if isinstance(spec, tuple):
        return spec

    if ":" in spec:
        name, weight_str = spec.split(":", 1)
        parts = [float(x) for x in weight_str.split(",")]
        weight: Union[float, Tuple[float, ...]] = (
            parts[0] if len(parts) == 1 else tuple(parts)
        )
    else:
        name = spec
        weight = cls.get(name)["default_weight"]

    # Validate weight count
    got = len(weight) if isinstance(weight, tuple) else 1
    default_weight = cls.get(name)["default_weight"]
    expected = len(default_weight) if isinstance(default_weight, tuple) else 1

    if got != expected:
        raise ValueError(
            f"Cost function '{name}' expects {expected} weight(s), got {got}."
        )

    return name, weight

Evolution Engine#

from qml_essentials.jaqsi import Evolution
Source code in qml_essentials/evolution.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
class Evolution:
    # Module-level cache for JIT-compiled ODE solvers.  Keyed on
    # (coeff_fn_id, dim, atol, rtol, max_steps, throw) so that all
    # evolve() calls with the same pulse shape function and matrix size
    # share one compiled XLA program.  This turns O(n_gates) JIT
    # compilations into O(n_distinct_pulse_shapes) during pulse-mode
    # circuit building.
    _evolve_solver_cache: dict = {}
    _evolve_solver_cache_lock = threading.Lock()

    # Default solver knobs for parametrized (time-dependent) evolution.
    # These can be overridden per-call via the **odeint_kwargs of
    # ``evolve()`` or globally via :meth:`set_solver_defaults`.
    #
    # ``max_steps`` is the hard cap on accepted ODE steps.  Pulse-level
    # workloads at on-resonance carriers (ω_c ≈ ω_q) require many more
    # steps than the diffrax default during JIT — 2**13 = 8192 is
    # large enough for realistic single- and two-qubit pulses while
    # remaining cheap to compile.
    #
    # ``throw`` controls whether diffrax raises on solver failure
    # (e.g. ``MaxStepsReached``).  When set to ``False`` the gate
    # factory instead returns a NaN-filled unitary so the calling
    # optimiser sees a well-defined (but useless) result and can
    # gracefully reject the candidate.

    # ``solver`` selects the time-integration backend for the
    # interaction-picture ODE ``dU/dt = -i H_I(t) U``:
    #
    #   * ``"dopri8"`` (default) — adaptive Dormand-Prince 8(7) via
    #     diffrax.  Robust but expensive on highly oscillatory drives
    #     because the step controller resolves every fast cycle.
    #   * ``"dopri5"`` — TODO description
    #   * ``"magnus2"`` — commutator-free Magnus, 2nd order (midpoint
    #     rule) on a fixed ``magnus_steps`` grid via ``jax.lax.scan``.
    #     One ``expm`` per step.  Preserves unitarity to machine
    #     precision and fuses into a single XLA program.
    #   * ``"magnus4"`` — commutator-free Magnus, 4th order (CFM4:2 of
    #     Blanes & Moan) on a fixed ``magnus_steps`` grid.  Two ``H``
    #     evaluations and two ``expm`` per step; typically the best
    #     accuracy/cost trade-off for smooth oscillatory pulse drives.
    #
    # ``magnus_steps`` is the number of fixed substeps for the Magnus
    # integrators (ignored for ``dopri8``).  Choose it so that ``h =
    # T/N`` resolves the fastest oscillation in ``H(t)`` (~few steps
    # per period of the highest frequency).
    _solver_defaults: dict = {
        "max_steps": 2**13,
        "throw": True,
        "solver": "dopri8",
        "magnus_steps": 256,
    }
    _valid_solvers = ("dopri8", "dopri5", "magnus2", "magnus4")

    @classmethod
    def set_solver_defaults(
        cls,
        max_steps: Optional[int] = None,
        throw: Optional[bool] = None,
        solver: Optional[str] = None,
        magnus_steps: Optional[int] = None,
    ) -> dict:
        """Update class-level solver defaults; return the previous values.

        The returned dictionary is suitable for restoring the previous
        defaults via ``set_solver_defaults(**prev)``.

        Args:
            max_steps: New default for ``max_steps`` (ignored if ``None``).
            throw: New default for ``throw`` (ignored if ``None``).

        Returns:
            Dictionary with the previous values of the updated keys.
        """
        prev: dict = {}
        if max_steps is not None:
            prev["max_steps"] = cls._solver_defaults["max_steps"]
            cls._solver_defaults["max_steps"] = int(max_steps)
        if throw is not None:
            prev["throw"] = cls._solver_defaults["throw"]
            cls._solver_defaults["throw"] = bool(throw)
        if solver is not None:
            if solver not in cls._valid_solvers:
                raise ValueError(
                    f"Unknown solver {solver!r}; expected one of {cls._valid_solvers}"
                )
            prev["solver"] = cls._solver_defaults["solver"]
            cls._solver_defaults["solver"] = solver
        if magnus_steps is not None:
            prev["magnus_steps"] = cls._solver_defaults["magnus_steps"]
            cls._solver_defaults["magnus_steps"] = int(magnus_steps)
        return prev

    @classmethod
    def _store_evolve_solver(cls, cache_key: tuple, solve: Callable) -> Callable:
        """Cache a compiled evolve solver unless another thread won the race."""
        with cls._evolve_solver_cache_lock:
            existing = cls._evolve_solver_cache.get(cache_key)
            if existing is not None:
                return existing
            cls._evolve_solver_cache[cache_key] = solve
        return solve

    @classmethod
    def clear_evolve_solver_cache(cls) -> None:
        """Drop every cached compiled evolve solver.

        Call this whenever the coefficient functions referenced by the
        cache keys are rebuilt (e.g. when :class:`PulseGates` swaps in
        a new pulse envelope, RWA flag or frame).  Without an explicit
        eviction the cache keeps the old code objects alive and would
        also retain XLA programs that no longer match any active
        coefficient function.
        """
        with cls._evolve_solver_cache_lock:
            cls._evolve_solver_cache.clear()

    @classmethod
    def _parse_evolve_solver_options(cls, odeint_kwargs: dict) -> tuple:
        """Pop and validate solver options from ``evolve(..., **odeint_kwargs)``."""
        default_tol = 1.0e-10 if jax.config.x64_enabled else 1.4e-8
        atol = odeint_kwargs.pop("atol", default_tol)
        rtol = odeint_kwargs.pop("rtol", default_tol)
        max_steps = int(
            odeint_kwargs.pop("max_steps", cls._solver_defaults["max_steps"])
        )
        throw = bool(odeint_kwargs.pop("throw", cls._solver_defaults["throw"]))
        solver_name = str(odeint_kwargs.pop("solver", cls._solver_defaults["solver"]))
        if solver_name not in cls._valid_solvers:
            raise ValueError(
                f"Unknown solver {solver_name!r}; expected one of {cls._valid_solvers}"
            )
        magnus_steps = int(
            odeint_kwargs.pop("magnus_steps", cls._solver_defaults["magnus_steps"])
        )
        return atol, rtol, max_steps, throw, solver_name, magnus_steps

    @classmethod
    def _build_magnus_evolve_solver(
        cls,
        cache_key: tuple,
        coeff_fns: Tuple[Callable, ...],
        n_terms: int,
        dim: int,
        solver_name: str,
        magnus_steps: int,
    ) -> Callable:
        """Build and cache a fixed-step commutator-free Magnus solver."""
        _coeff_fns = coeff_fns
        _cdtype_local = jnp.complex128 if jax.config.x64_enabled else jnp.complex64
        n_steps = magnus_steps
        solver_name_local = solver_name

        @eqx.filter_jit
        def _solve(neg_iH_split, params, t0, t1):
            # Reconstruct the per-term complex matrices ``-i H_i`` from their
            # split (Re, Im) representation so the coefficient sum is a single
            # complex tensordot.
            A_all = neg_iH_split[:, 0]
            B_all = neg_iH_split[:, 1]
            neg_iH = (A_all + 1j * B_all).astype(_cdtype_local)

            h = (t1 - t0) / n_steps

            def H_at(t):
                c = jnp.stack(
                    [
                        jnp.asarray(_coeff_fns[i](params[i], t)).reshape(())
                        for i in range(n_terms)
                    ]
                ).astype(_cdtype_local)
                return jnp.tensordot(c, neg_iH, axes=1)

            if solver_name_local == "magnus2":

                def step(U, n):
                    tn = t0 + n * h
                    Omega = h * H_at(tn + 0.5 * h)
                    return jax.scipy.linalg.expm(Omega) @ U, None

            else:
                sqrt3 = math.sqrt(3.0)
                c1 = 0.5 - sqrt3 / 6.0
                c2 = 0.5 + sqrt3 / 6.0
                a1 = 0.25 + sqrt3 / 6.0
                a2 = 0.25 - sqrt3 / 6.0

                def step(U, n):
                    tn = t0 + n * h
                    H1 = H_at(tn + c1 * h)
                    H2 = H_at(tn + c2 * h)
                    Omega_a = h * (a1 * H1 + a2 * H2)
                    Omega_b = h * (a2 * H1 + a1 * H2)
                    # CFM4:2 ordering (Blanes & Moan 2006, Table II):
                    # U_{n+1} = exp(Ω_b) · exp(Ω_a) · U_n.
                    U_next = (
                        jax.scipy.linalg.expm(Omega_b)
                        @ jax.scipy.linalg.expm(Omega_a)
                        @ U
                    )
                    return U_next, None

            U0 = jnp.eye(dim, dtype=_cdtype_local)
            U_final, _ = jax.lax.scan(step, U0, jnp.arange(n_steps))
            return U_final

        return cls._store_evolve_solver(cache_key, _solve)

    @classmethod
    def _build_diffrax_evolve_solver(
        cls,
        cache_key: tuple,
        coeff_fns: Tuple[Callable, ...],
        n_terms: int,
        dim: int,
        atol: float,
        rtol: float,
        max_steps: int,
        throw: bool,
        solver_name: str,
        _rdtype,
    ) -> Callable:
        """Build and cache an adaptive diffrax-based evolve solver."""
        solver = diffrax.Dopri8() if solver_name == "dopri8" else diffrax.Dopri5()
        stepsize_controller = diffrax.PIDController(atol=atol, rtol=rtol)
        _coeff_fns = coeff_fns

        @eqx.filter_jit
        def _solve(neg_iH_split, params, t0, t1):
            """Solve dU/dt = sum_i f_i(p_i, t) * (-iH_i) * U from t0 to t1.

            ``neg_iH_split`` has shape ``(n_terms, 2, dim, dim)`` with
            ``[:, 0]`` = Re(-iH_i) and ``[:, 1]`` = Im(-iH_i).
            ``params`` is a list/tuple of length ``n_terms`` carrying
            each term's coefficient parameters.  The state ``y`` has
            shape ``(2, dim, dim)`` with ``y[0] = Re(U)`` and
            ``y[1] = Im(U)``.
            """
            A_all = neg_iH_split[:, 0]
            B_all = neg_iH_split[:, 1]

            def rhs(t, y, args):
                # Each coefficient function must return a scalar value; some
                # call sites pass a shape-(1,) param array, so coerce to a
                # true scalar before stacking.
                c = jnp.stack(
                    [
                        jnp.asarray(_coeff_fns[i](args[i], t)).reshape(())
                        for i in range(n_terms)
                    ]
                )
                u_re = y[0]
                u_im = y[1]
                A_eff = jnp.tensordot(c, A_all, axes=1)
                B_eff = jnp.tensordot(c, B_all, axes=1)
                du_re = A_eff @ u_re - B_eff @ u_im
                du_im = A_eff @ u_im + B_eff @ u_re
                return jnp.stack([du_re, du_im], axis=0)

            y0 = jnp.stack(
                [
                    jnp.eye(dim, dtype=_rdtype),
                    jnp.zeros((dim, dim), dtype=_rdtype),
                ],
                axis=0,
            )

            sol = diffrax.diffeqsolve(
                diffrax.ODETerm(rhs),
                solver,
                t0=t0,
                t1=t1,
                dt0=None,
                y0=y0,
                args=params,
                stepsize_controller=stepsize_controller,
                max_steps=max_steps,
                throw=throw,
            )

            y_final = sol.ys[0]
            U = y_final[0] + 1j * y_final[1]

            if not throw:
                successful = sol.result == diffrax.RESULTS.successful
                U = jnp.where(successful, U, jnp.full_like(U, jnp.nan))
            return U

        return cls._store_evolve_solver(cache_key, _solve)

    @classmethod
    def evolve(
        cls,
        hamiltonian: Union["Hermitian", "ParametrizedHamiltonian"],
        name: Optional[str] = None,
        **odeint_kwargs: Any,
    ) -> Callable:
        """Return a gate-factory for Hamiltonian time evolution.

        Engine for the :meth:`Hermitian.evolve` / :meth:`ParametrizedHamiltonian.evolve`
        methods (the usual entry point); it dispatches on the Hamiltonian type.

        Supports two modes:

        Static — when *hamiltonian* is a :class:`Hermitian`::

            gate = Hermitian(H_mat, wires=0).evolve()
            gate(t=0.5)            # U = exp(-i*0.5*H)

        Time-dependent — when *hamiltonian* is a
        :class:`ParametrizedHamiltonian` (created via ``coeff_fn * Hermitian``)::

            H_td = coeff_fn * Hermitian(H_mat, wires=0)
            gate = H_td.evolve()
            gate([A, sigma], T)    # U via ODE: dU/dt = -i f(p,t) H * U

        The time-dependent case solves the Schrödinger equation numerically
        using ``diffrax.diffeqsolve`` with a Dopri8 adaptive Runge-Kutta
        solver

        All computations are pure JAX and fully differentiable with
        ``jax.grad``.

        Args:
            hamiltonian: Either a :class:`Hermitian` (static evolution) or a
                :class:`ParametrizedHamiltonian` (time-dependent evolution).
            **odeint_kwargs: Extra keyword arguments.  Recognised keys:

                - ``atol``, ``rtol`` — absolute/relative tolerances for the
                adaptive step-size controller (default ``1.4e-8``).

        Returns:
            A callable gate factory.  Signature depends on the mode:

            - Static: ``(t, wires=0) -> Operation``
            - Time-dependent: ``(coeff_args, T) -> Operation``

        Raises:
            TypeError: If *hamiltonian* is neither ``Hermitian`` nor
                ``ParametrizedHamiltonian``.
        """
        if isinstance(hamiltonian, Hermitian):
            return cls._evolve_static(hamiltonian, name=name)
        elif isinstance(hamiltonian, ParametrizedHamiltonian):
            return cls._evolve_parametrized(hamiltonian, name=name, **odeint_kwargs)
        else:
            raise TypeError(
                f"evolve() expects a Hermitian or ParametrizedHamiltonian, "
                f"got {type(hamiltonian)}"
            )

    @staticmethod
    def _evolve_static(hermitian: Hermitian, name: Optional[str] = None) -> Callable:
        """Gate factory for static Hamiltonian evolution U = exp(-i t H)."""
        H_mat = hermitian.matrix

        def _apply(t: float, wires: Union[int, List[int]] = 0) -> Operation:
            U = jax.scipy.linalg.expm(-1j * t * H_mat)
            return Operation(wires=wires, matrix=U, name=name)

        return _apply

    @classmethod
    def _evolve_parametrized(
        cls,
        ph: ParametrizedHamiltonian,
        name: Optional[str] = None,
        **odeint_kwargs: Any,
    ) -> Callable:
        """Gate factory for time-dependent (multi-term) Hamiltonian evolution.

        Solves the matrix ODE

            dU/dt = -i [\\sum_i f_i(params_i, t) * H_i] * U,    U(0) = I

        with ``diffrax.diffeqsolve`` (Dopri8 adaptive RK).  The Hamiltonian
        may contain one or more ``coeff_fn * Hermitian`` terms (see
        :class:`ParametrizedHamiltonian`); the single-term case is the
        usual ``coeff_fn * Hermitian`` and is fully backward compatible.

        Implementation notes:

        - To avoid diffrax's experimental complex dtype path, the ODE is
          reformulated in real arithmetic.  Writing ``-iH_i = A_i + i B_i``
          and ``U = U_re + i U_im``, each term contributes::

              d(U_re)/dt += f_i(p_i,t) * (A_i @ U_re - B_i @ U_im)
              d(U_im)/dt += f_i(p_i,t) * (A_i @ U_im + B_i @ U_re)

        - ``-iH_i`` is precomputed once per term and stacked into a
          ``(n_terms, 2, dim, dim)`` real array, contracted via
          ``einsum`` against the per-step coefficient vector
          ``c = [f_0(p_0,t), ..., f_{n-1}(p_{n-1},t)]``.

        - The JIT-compiled solver is cached per coefficient-function code
          tuple (and ``dim``, tolerances) so multiple ``evolve()`` calls
          with the same pulse shape — but different Hamiltonian matrices
          or parameters — reuse the same compiled XLA program.

        TODO: switch back once diffrax is stable with complex arithmetic.

        Args:
            ph: A :class:`ParametrizedHamiltonian` (one or more terms).
            **odeint_kwargs: Keyword arguments forwarded to
                ``diffrax.diffeqsolve``.  Recognised keys:

                - ``atol``, ``rtol`` — absolute/relative tolerances for the
                  step-size controller (default ``1.4e-8`` in fp32 mode,
                  ``1.0e-10`` in fp64 mode).
                - ``max_steps`` — hard cap on accepted ODE steps
                  (default :attr:`cls._solver_defaults['max_steps']`,
                  currently ``2**14``).  Increase this if the integrator
                  raises ``MaxStepsReached`` for a stiff/oscillatory
                  pulse Hamiltonian.
                - ``throw`` — whether to raise on solver failure
                  (default :attr:`cls._solver_defaults['throw']`,
                  currently ``True``).  When ``False``, a failed
                  integration returns a NaN-filled unitary instead of
                  raising; this is the recommended setting for inner
                  loops of an optimiser (e.g. QOC Stage 0) so a single
                  pathological candidate cannot abort the whole run.
        """
        coeff_fns = ph.coeff_fns  # tuple of callables
        H_mats = ph.H_mats  # tuple of (dim, dim)
        wires = ph.wires
        n_terms = ph.n_terms
        dim = H_mats[0].shape[0]

        # Pre-compute -i*H_i for each term and split into real / imaginary
        # parts so the ODE RHS uses only real arithmetic.  Final shape:
        # (n_terms, 2, dim, dim).
        neg_iH_split_per_term = []
        for H_mat in H_mats:
            neg_iH = -1j * H_mat
            neg_iH_split_per_term.append(
                jnp.stack([jnp.real(neg_iH), jnp.imag(neg_iH)], axis=0)
            )
        neg_iH_split = jnp.stack(neg_iH_split_per_term, axis=0)

        # Real dtype matching the precision mode
        # consider decreasing if no convergence
        _rdtype = jnp.float64 if jax.config.x64_enabled else jnp.float32

        # Pick tolerances according to precision + some headroom
        atol, rtol, max_steps, throw, solver_name, magnus_steps = (
            cls._parse_evolve_solver_options(odeint_kwargs)
        )

        # Cache key:  every coeff fn's code object (same shape of pulse
        # fns -> same JIT program) plus dim, tolerances, and solver
        # budget / throw flag (different budgets mean different XLA
        # programs).  We use the code object itself (hashable, identity-
        # equal) rather than ``id(fn.__code__)``: ids can be reused for
        # later code objects after the original is garbage-collected,
        # which would silently return a stale compiled solver for a
        # different pulse shape.  Holding the code object in the cache
        # keeps it alive for as long as the cached program is valid.
        cache_key = (
            tuple(fn.__code__ for fn in coeff_fns),
            dim,
            atol,
            rtol,
            max_steps,
            throw,
            solver_name,
            magnus_steps,
        )

        with cls._evolve_solver_cache_lock:
            _solve = cls._evolve_solver_cache.get(cache_key)
        if _solve is None:
            if solver_name in ("magnus2", "magnus4"):
                _solve = cls._build_magnus_evolve_solver(
                    cache_key=cache_key,
                    coeff_fns=coeff_fns,
                    n_terms=n_terms,
                    dim=dim,
                    solver_name=solver_name,
                    magnus_steps=magnus_steps,
                )
            else:
                _solve = cls._build_diffrax_evolve_solver(
                    cache_key=cache_key,
                    coeff_fns=coeff_fns,
                    n_terms=n_terms,
                    dim=dim,
                    atol=atol,
                    rtol=rtol,
                    max_steps=max_steps,
                    throw=throw,
                    solver_name=solver_name,
                    _rdtype=_rdtype,
                )

        def _apply(coeff_args, T) -> Operation:
            """Evolve under the (multi-term) time-dependent Hamiltonian.

            Args:
                coeff_args: List/tuple of parameter sets, one per term.
                    For single-term Hamiltonians the legacy form
                    ``[params]`` works unchanged; ``params`` is forwarded
                    to the sole coefficient function.
                T: Total evolution time.  Scalar -> integrate on
                    ``[0, T]``; 2-element -> integrate on ``[T[0], T[1]]``.

            Returns:
                An :class:`Operation` wrapping the computed unitary.
            """
            # Normalise to a tuple of length n_terms.  Accept a bare
            # single-term arg for backward compat.
            if isinstance(coeff_args, (list, tuple)):
                params = tuple(coeff_args)
            else:
                params = (coeff_args,)

            if len(params) != n_terms:
                raise ValueError(
                    f"Expected {n_terms} parameter set(s) for a "
                    f"{n_terms}-term ParametrizedHamiltonian, "
                    f"got {len(params)}."
                )

            # Build time span — resolve at Python level to avoid traced
            # branching.  ``T`` is either a Python scalar / 0-d array (=> integrate
            # on [0, T]) or a 2-element sequence/array (=> integrate on [T[0], T[1]]).
            # Let ``_solve`` cast t0/t1 to its working dtype; we only need the
            # array form to know the rank.
            T_arr = jnp.asarray(T, dtype=_rdtype)
            if T_arr.ndim == 0:
                t0 = _rdtype(0.0)
                t1 = T_arr
            else:
                t0 = T_arr[0]
                t1 = T_arr[1]

            U = _solve(neg_iH_split, params, t0, t1)

            return Operation(wires=wires, matrix=U, name=name)

        return _apply

clear_evolve_solver_cache() classmethod #

Drop every cached compiled evolve solver.

Call this whenever the coefficient functions referenced by the cache keys are rebuilt (e.g. when :class:PulseGates swaps in a new pulse envelope, RWA flag or frame). Without an explicit eviction the cache keeps the old code objects alive and would also retain XLA programs that no longer match any active coefficient function.

Source code in qml_essentials/evolution.py
@classmethod
def clear_evolve_solver_cache(cls) -> None:
    """Drop every cached compiled evolve solver.

    Call this whenever the coefficient functions referenced by the
    cache keys are rebuilt (e.g. when :class:`PulseGates` swaps in
    a new pulse envelope, RWA flag or frame).  Without an explicit
    eviction the cache keeps the old code objects alive and would
    also retain XLA programs that no longer match any active
    coefficient function.
    """
    with cls._evolve_solver_cache_lock:
        cls._evolve_solver_cache.clear()

evolve(hamiltonian, name=None, **odeint_kwargs) classmethod #

Return a gate-factory for Hamiltonian time evolution.

Engine for the :meth:Hermitian.evolve / :meth:ParametrizedHamiltonian.evolve methods (the usual entry point); it dispatches on the Hamiltonian type.

Supports two modes:

Static — when hamiltonian is a :class:Hermitian::

gate = Hermitian(H_mat, wires=0).evolve()
gate(t=0.5)            # U = exp(-i*0.5*H)

Time-dependent — when hamiltonian is a :class:ParametrizedHamiltonian (created via coeff_fn * Hermitian)::

H_td = coeff_fn * Hermitian(H_mat, wires=0)
gate = H_td.evolve()
gate([A, sigma], T)    # U via ODE: dU/dt = -i f(p,t) H * U

The time-dependent case solves the Schrödinger equation numerically using diffrax.diffeqsolve with a Dopri8 adaptive Runge-Kutta solver

All computations are pure JAX and fully differentiable with jax.grad.

Parameters:

Name Type Description Default
hamiltonian Union[Hermitian, ParametrizedHamiltonian]

Either a :class:Hermitian (static evolution) or a :class:ParametrizedHamiltonian (time-dependent evolution).

required
**odeint_kwargs Any

Extra keyword arguments. Recognised keys:

  • atol, rtol — absolute/relative tolerances for the adaptive step-size controller (default 1.4e-8).
{}

Returns:

Type Description
Callable

A callable gate factory. Signature depends on the mode:

Callable
  • Static: (t, wires=0) -> Operation
Callable
  • Time-dependent: (coeff_args, T) -> Operation

Raises:

Type Description
TypeError

If hamiltonian is neither Hermitian nor ParametrizedHamiltonian.

Source code in qml_essentials/evolution.py
@classmethod
def evolve(
    cls,
    hamiltonian: Union["Hermitian", "ParametrizedHamiltonian"],
    name: Optional[str] = None,
    **odeint_kwargs: Any,
) -> Callable:
    """Return a gate-factory for Hamiltonian time evolution.

    Engine for the :meth:`Hermitian.evolve` / :meth:`ParametrizedHamiltonian.evolve`
    methods (the usual entry point); it dispatches on the Hamiltonian type.

    Supports two modes:

    Static — when *hamiltonian* is a :class:`Hermitian`::

        gate = Hermitian(H_mat, wires=0).evolve()
        gate(t=0.5)            # U = exp(-i*0.5*H)

    Time-dependent — when *hamiltonian* is a
    :class:`ParametrizedHamiltonian` (created via ``coeff_fn * Hermitian``)::

        H_td = coeff_fn * Hermitian(H_mat, wires=0)
        gate = H_td.evolve()
        gate([A, sigma], T)    # U via ODE: dU/dt = -i f(p,t) H * U

    The time-dependent case solves the Schrödinger equation numerically
    using ``diffrax.diffeqsolve`` with a Dopri8 adaptive Runge-Kutta
    solver

    All computations are pure JAX and fully differentiable with
    ``jax.grad``.

    Args:
        hamiltonian: Either a :class:`Hermitian` (static evolution) or a
            :class:`ParametrizedHamiltonian` (time-dependent evolution).
        **odeint_kwargs: Extra keyword arguments.  Recognised keys:

            - ``atol``, ``rtol`` — absolute/relative tolerances for the
            adaptive step-size controller (default ``1.4e-8``).

    Returns:
        A callable gate factory.  Signature depends on the mode:

        - Static: ``(t, wires=0) -> Operation``
        - Time-dependent: ``(coeff_args, T) -> Operation``

    Raises:
        TypeError: If *hamiltonian* is neither ``Hermitian`` nor
            ``ParametrizedHamiltonian``.
    """
    if isinstance(hamiltonian, Hermitian):
        return cls._evolve_static(hamiltonian, name=name)
    elif isinstance(hamiltonian, ParametrizedHamiltonian):
        return cls._evolve_parametrized(hamiltonian, name=name, **odeint_kwargs)
    else:
        raise TypeError(
            f"evolve() expects a Hermitian or ParametrizedHamiltonian, "
            f"got {type(hamiltonian)}"
        )

set_solver_defaults(max_steps=None, throw=None, solver=None, magnus_steps=None) classmethod #

Update class-level solver defaults; return the previous values.

The returned dictionary is suitable for restoring the previous defaults via set_solver_defaults(**prev).

Parameters:

Name Type Description Default
max_steps Optional[int]

New default for max_steps (ignored if None).

None
throw Optional[bool]

New default for throw (ignored if None).

None

Returns:

Type Description
dict

Dictionary with the previous values of the updated keys.

Source code in qml_essentials/evolution.py
@classmethod
def set_solver_defaults(
    cls,
    max_steps: Optional[int] = None,
    throw: Optional[bool] = None,
    solver: Optional[str] = None,
    magnus_steps: Optional[int] = None,
) -> dict:
    """Update class-level solver defaults; return the previous values.

    The returned dictionary is suitable for restoring the previous
    defaults via ``set_solver_defaults(**prev)``.

    Args:
        max_steps: New default for ``max_steps`` (ignored if ``None``).
        throw: New default for ``throw`` (ignored if ``None``).

    Returns:
        Dictionary with the previous values of the updated keys.
    """
    prev: dict = {}
    if max_steps is not None:
        prev["max_steps"] = cls._solver_defaults["max_steps"]
        cls._solver_defaults["max_steps"] = int(max_steps)
    if throw is not None:
        prev["throw"] = cls._solver_defaults["throw"]
        cls._solver_defaults["throw"] = bool(throw)
    if solver is not None:
        if solver not in cls._valid_solvers:
            raise ValueError(
                f"Unknown solver {solver!r}; expected one of {cls._valid_solvers}"
            )
        prev["solver"] = cls._solver_defaults["solver"]
        cls._solver_defaults["solver"] = solver
    if magnus_steps is not None:
        prev["magnus_steps"] = cls._solver_defaults["magnus_steps"]
        cls._solver_defaults["magnus_steps"] = int(magnus_steps)
    return prev

Script#

from qml_essentials.script import Script

Circuit container and executor backed by pure JAX kernels.

Script takes a callable f representing a quantum circuit. Within f, :class:~qml_essentials.operations.Operation objects are instantiated and automatically recorded onto a tape. The tape is then simulated using either a statevector or density-matrix kernel depending on whether noise channels are present.

The stateless simulation/measurement kernels live in :mod:qml_essentials.simulation and the memory-estimation/chunking helpers in :mod:qml_essentials.memory; this class orchestrates recording, batching, caching, and drawing around them.

Attributes:

Name Type Description
f

The circuit function whose body instantiates Operation objects.

_n_qubits

Optionally pre-declared number of qubits. When None the qubit count is inferred from the operations recorded on the tape.

Example

def circuit(theta): ... RX(theta, wires=0) ... PauliZ(wires=1) script = Script(circuit, n_qubits=2) result = script.execute(type="expval", obs=[PauliZ(0)])

Source code in qml_essentials/script.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
class Script:
    """Circuit container and executor backed by pure JAX kernels.

    ``Script`` takes a callable *f* representing a quantum circuit.
    Within *f*, :class:`~qml_essentials.operations.Operation` objects are
    instantiated and automatically recorded onto a tape.  The tape is then
    simulated using either a statevector or density-matrix kernel depending on
    whether noise channels are present.

    The stateless simulation/measurement kernels live in
    :mod:`qml_essentials.simulation` and the memory-estimation/chunking helpers
    in :mod:`qml_essentials.memory`; this class orchestrates recording,
    batching, caching, and drawing around them.

    Attributes:
        f: The circuit function whose body instantiates ``Operation`` objects.
        _n_qubits: Optionally pre-declared number of qubits.  When ``None``
            the qubit count is inferred from the operations recorded on the
            tape.

    Example:
        >>> def circuit(theta):
        ...     RX(theta, wires=0)
        ...     PauliZ(wires=1)
        >>> script = Script(circuit, n_qubits=2)
        >>> result = script.execute(type="expval", obs=[PauliZ(0)])
    """

    def __init__(self, f: Callable[..., None], n_qubits: Optional[int] = None) -> None:
        """Initialise a Script.

        Args:
            f: A function whose body instantiates ``Operation`` objects.
                Signature: ``f(*args, **kwargs) -> None``.
            n_qubits: Number of qubits.  If ``None``, inferred from the
                operations recorded on the tape.
        """
        self.f = f
        self._n_qubits = n_qubits
        self._jit_cache: dict = {}  # keyed on (type, in_axes, arg_shapes, gateError)

    def _record(self, *args, **kwargs) -> List[Operation]:
        """Run the circuit function and collect the recorded operations.

        Uses :func:`~qml_essentials.tape.recording` as a context manager so
        that the tape is always cleaned up — even if the circuit function
        raises — and nested recordings (e.g. from ``_execute_batched``) each
        get their own independent tape.

        Args:
            *args: Positional arguments forwarded to the circuit function.
            **kwargs: Keyword arguments forwarded to the circuit function.

        Returns:
            List of :class:`~qml_essentials.operations.Operation` instances in
            the order they were instantiated.
        """
        with recording() as tape:
            self.f(*args, **kwargs)
        return tape

    def pulse_events(self, *args, **kwargs) -> list:
        """Run the circuit and collect pulse events emitted by PulseGates.

        Activates both the normal operation tape (so gates execute) and
        a pulse-event tape that captures
        :class:`~qml_essentials.drawing.PulseEvent` objects from leaf
        pulse gates (RX, RY, RZ, CZ).

        Args:
            *args (Any): Forwarded to the circuit function.
            **kwargs (Any): Forwarded to the circuit function.

        Returns:
            List of :class:`~qml_essentials.drawing.PulseEvent`.
        """
        with pulse_recording() as events:
            with recording():
                self.f(*args, **kwargs)
        return events

    def execute(
        self,
        type: str = "expval",
        obs: Optional[List[Operation]] = None,
        *,
        args: tuple = (),
        kwargs: Optional[dict] = None,
        in_axes: Optional[Tuple] = None,
        shots: Optional[int] = None,
        key: Optional[jnp.ndarray] = None,
    ) -> jnp.ndarray:
        """Execute the circuit and return measurement results.

        Args:
            type: Measurement type.  One of:

                - ``"expval"``  — expectation value
                    \\langle\\psi|O|\\psi\\rangle / Tr(O\\rho )
                    for each observable in *obs*.
                - ``"probs"``   — probability vector of shape ``(2**n,)``.
                - ``"state"``   — raw statevector of shape ``(2**n,)``.
                - ``"density"`` — full density matrix of shape
                  ``(2**n, 2**n)``.

            obs: Observables required when type is ``"expval"``.
            args: Positional arguments forwarded to the circuit function f.
            kwargs: Keyword arguments forwarded to f.
            in_axes: Batch axes for each element of *args*, following the same
                convention as ``jax.vmap``:

                - An integer selects that axis of the corresponding array as
                  the batch dimension.
                - ``None`` broadcasts the argument (no batching).

                When provided, :meth:`execute` calls ``jax.vmap`` over the
                pure simulation kernel and returns results with a leading
                batch dimension.
            shots: Number of measurement shots for stochastic sampling.
                If ``None`` (default), exact analytic results are returned.
                Only supported for ``"probs"`` and ``"expval"`` measurement
                types.
            key: JAX PRNG key for shot sampling.  If ``None`` and *shots*
                is set, a default key ``jax.random.PRNGKey(0)`` is used.

        Returns:
            Without in_axes: shape determined by type.
            With in_axes: shape ``(B, ...)`` with a leading batch dimension.
        """
        if obs is None:
            obs = []
        if kwargs is None:
            kwargs = {}
        if shots is not None and key is None:
            key = jax.random.PRNGKey(0)

        # Split single/ parallel execution
        # TODO: we might want to unify the n_qubit stuff such that we can eliminate
        # the parameter to this method entirely
        if in_axes is not None:
            return self._execute_batched(
                type=type,
                obs=obs,
                args=args,
                kwargs=kwargs,
                in_axes=in_axes,
                shots=shots,
                key=key,
            )
        else:
            tape = self._record(*args, **kwargs)
            n_qubits = self._n_qubits or simulation.infer_n_qubits(tape, obs)

            use_density = simulation.uses_density(tape, type)

            return simulation.simulate_and_measure(
                tape,
                n_qubits,
                type,
                obs,
                use_density,
                shots=shots,
                key=key,
            )

    @staticmethod
    def _args_contain_tracer(args: tuple) -> bool:
        """Return ``True`` if any leaf of *args* is a JAX tracer.

        When :meth:`execute` runs under an outer transform (``jax.grad``,
        ``jax.jacrev``, an enclosing ``jax.jit``/``vmap``) the positional
        arguments are tracers rather than concrete arrays.  The tracer-tolerant
        ``eqx.filter_jit`` wrapper is still reused in that case (its closure
        captures only concrete metadata), but the concrete-only fast path — the
        ahead-of-time-compiled XLA executable — is invalid for tracers and must
        be skipped.
        """
        return any(
            isinstance(x, jax.core.Tracer) for x in jax.tree_util.tree_leaves(args)
        )

    @staticmethod
    def _batch_size(args: tuple, in_axes: Tuple) -> int:
        """Size of the batch dimension, read from the first batched argument."""
        for a, ax in zip(args, in_axes):
            if ax is not None:
                return a.shape[ax]
        return 1

    @staticmethod
    def _slice_first(a: Any, ax: int) -> Any:
        """Take the first element along axis *ax*.

        Uses ``jax.lax.index_in_dim`` rather than ``jnp.take`` because JAX
        random-key arrays do not support ``jnp.take``.
        """
        # TODO: fix once that is available in JAX
        return jax.lax.index_in_dim(a, 0, axis=ax, keepdims=False)

    def _record_metadata(
        self, scalar_args: tuple, kwargs: dict, obs: List[Operation], type: str
    ) -> Tuple[int, bool, int]:
        """Trace the tape from scalar slices to derive batch-invariant metadata.

        Recording once with scalar slices determines ``n_qubits`` and whether
        noise channels are present (forcing density-matrix simulation) without
        running the full batch.

        Returns:
            ``(n_qubits, use_density, n_ops)``.
        """
        tape = self._record(*scalar_args, **kwargs)
        n_qubits = self._n_qubits or simulation.infer_n_qubits(tape, obs)
        use_density = simulation.uses_density(tape, type)
        return n_qubits, use_density, len(tape)

    def _build_plan(
        self,
        type: str,
        obs: List[Operation],
        args: tuple,
        kwargs: dict,
        in_axes: Tuple,
    ) -> _BatchPlan:
        """Trace the circuit once and build the cacheable execution plan.

        Records the tape from scalar slices of *args* (to derive
        ``n_qubits``/noise), then builds the vmapped ``eqx.filter_jit``
        wrapper.  When every positional argument is array-like (so plain
        ``jax.jit`` — which has no static-argument handling — is valid) an
        AOT-eligible plain ``jax.jit`` wrapper is built too; :meth:`_dispatch`
        lowers and compiles it lazily per batch size, and only with concrete
        args (the AOT path is gated off under a transform by the caller).
        """
        scalar_args = tuple(
            self._slice_first(a, ax) if ax is not None else a
            for a, ax in zip(args, in_axes)
        )
        n_qubits, use_density, n_ops = self._record_metadata(
            scalar_args, kwargs, obs, type
        )

        # Re-recording inside this closure is necessary: tape operations may
        # have matrices that depend on the batched argument (e.g. RX(theta)
        # with theta a tracer).  jax.vmap traces this once into a single XLA
        # computation spanning the whole batch.
        def _single_execute(*single_args):
            single_tape = self._record(*single_args, **kwargs)
            return simulation.simulate_and_measure(
                single_tape, n_qubits, type, obs, use_density
            )

        # Wrapping the vmapped function in eqx.filter_jit: (1) treats non-array
        # arguments as static, so circuit signatures mixing arrays and Python
        # values work; (2) lets the XLA program use intra-op CPU parallelism;
        # (3) caches compilation across calls with the same input shapes.
        # NOTE: when altering properties of the model, this might not get
        # re-compiled.
        # TODO: we might want to rework the data_reupload mechanism at some point
        batched_fn = eqx.filter_jit(jax.vmap(_single_execute, in_axes=in_axes))

        # AOT eligibility is a structural property of the signature: plain
        # ``jax.jit`` has no static-argument handling, so it is valid only when
        # every positional argument is array-like.  ``hasattr(a, "shape")`` is
        # true for concrete arrays, numpy arrays, and tracers, but false for
        # Python statics (str/None/dict).  Building the wrapper is pure (it
        # traces nothing); the lower+compile happens lazily in :meth:`_dispatch`
        # and only with concrete args, so this is safe to build under a
        # transform — its use is gated off there by the caller.
        plain_fn = None
        if all(hasattr(a, "shape") for a in args):
            plain_fn = jax.jit(jax.vmap(_single_execute, in_axes=in_axes))

        return _BatchPlan(batched_fn, plain_fn, n_qubits, use_density, n_ops)

    def _chunk_size(
        self,
        cache_key: tuple,
        plan: _BatchPlan,
        type: str,
        n_obs: int,
        batch_size: int,
    ) -> int:
        """Largest batch chunk that fits in memory, memoized per batch size.

        The result is cached under ``("_mem", cache_key, batch_size)`` to avoid
        repeated ``psutil`` syscalls across a tight repeated-call loop.
        """
        mem_key = ("_mem", cache_key, batch_size)
        chunk_size = self._jit_cache.get(mem_key)
        if chunk_size is None:
            chunk_size = memory.compute_chunk_size(
                plan.n_qubits,
                batch_size,
                type,
                plan.use_density,
                n_obs,
                n_ops=plan.n_ops,
            )
            self._jit_cache[mem_key] = chunk_size
        return chunk_size

    def _dispatch(
        self,
        aot_key: Optional[tuple],
        batched_fn: Callable,
        plain_fn: Optional[Callable],
        args: tuple,
        in_axes: Tuple,
        batch_size: int,
        chunk_size: int,
    ) -> jnp.ndarray:
        """Run a built plan through the leanest applicable path.

        - ``chunk_size < batch_size``: the full batch would not fit in memory,
          so execute it in memory-safe sub-batches via
          :func:`~qml_essentials.memory.execute_chunked`.
        - otherwise, when an AOT-eligible ``plain_fn`` exists, ahead-of-time
          lower+compile the vmapped kernel to an XLA executable (cached per
          ``aot_key``) and call it directly.  This skips both the per-call
          pytree partition/combine of :func:`eqx.filter_jit` and its
          just-in-time cache-key recomputation; for small circuits in a tight
          loop that dispatch overhead, not the XLA compute, dominates.
        - otherwise fall back to ``batched_fn`` (no ``plain_fn``: a non-array
          argument, shot mode, or running under a transform).
        """
        if chunk_size < batch_size:
            return memory.execute_chunked(
                batched_fn,
                args,
                in_axes,
                batch_size,
                chunk_size,
                clear_caches=memory.CLEAR_CACHES_BETWEEN_CHUNKS,
            )
        if plain_fn is None:
            return batched_fn(*args)
        compiled = self._jit_cache.get(aot_key)
        if compiled is None:
            compiled = plain_fn.lower(*args).compile()
            self._jit_cache[aot_key] = compiled
        return compiled(*args)

    def _execute_batched(
        self,
        type: str,
        obs: List[Operation],
        args: tuple,
        kwargs: dict,
        in_axes: Tuple,
        shots: Optional[int] = None,
        key: Optional[jnp.ndarray] = None,
    ) -> jnp.ndarray:
        """Vectorise :meth:`execute` over a batch axis using ``jax.vmap``.

        The circuit function is traced once in Python with scalar slices to
        record the tape, determine ``n_qubits``, and detect noise.  The
        resulting pure simulation kernel is then vmapped over the requested
        axes.

        Memory-aware chunking — before launching the full vmap, the
        method estimates peak memory usage.  If the full batch would exceed
        available RAM (with a safety margin), the batch is automatically
        split into sub-batches that fit.  Each chunk is vmapped independently
        and the results are concatenated.  This trades a small amount of
        wall-clock time for guaranteed execution without OOM.

        When the full batch fits in memory, there is zero overhead — the
        memory check is a pure Python arithmetic calculation (no JAX calls).

        Args:
            type: Measurement type (see :meth:`execute`).
            obs: Observables (see :meth:`execute`).
            args: Positional arguments for the circuit function.
            kwargs: Keyword arguments for the circuit function.
            in_axes: One entry per element of *args*.  Follows ``jax.vmap``
                convention: an int gives the batch axis; ``None`` broadcasts.
            shots: Number of measurement shots.  If ``None``, exact results.
            key: JAX PRNG key for shot sampling.

        Returns:
            Batched measurement results of shape ``(B, ...)`` where *B* is the
            size of the batch dimension.

        Raises:
            ValueError: If ``len(in_axes) != len(args)``.

        Note:
            The ``jax.vmap`` call in :meth:`_build_plan` is the exact
            boundary to replace with ``jax.shard_map`` for multi-device
            execution::

                from jax.sharding import PartitionSpec as P, Mesh
                result = jax.shard_map(
                    _single_execute, mesh=mesh,
                    in_specs=tuple(P(0) if ax is not None else P() for ax in in_axes),
                    out_specs=P(0),
                )(*args)
        """
        if len(in_axes) != len(args):
            raise ValueError(
                f"in_axes has {len(in_axes)} entries but args has {len(args)}. "
                "Provide one in_axes entry per positional argument."
            )

        batch_size = self._batch_size(args, in_axes)

        # Running under an outer JAX transform (e.g. ``jax.jacrev``) makes
        # ``args`` tracers.  The tracer-tolerant ``batched_fn`` wrapper is still
        # cached and reused (see exact-mode dispatch below); only the AOT
        # ``plain_fn`` executable is gated off, as it cannot accept tracers.
        in_transform = self._args_contain_tracer(args)

        arg_shapes = tuple(
            (a.shape, a.dtype) if hasattr(a, "shape") else type(a) for a in args
        )
        # TODO: we need to fix the dirty class-level `batch_gate_error` hack.
        # It is a global toggle that changes the compiled circuit, so it has to
        # take part in every cache key.
        gate_error = UnitaryGates.batch_gate_error

        # --- Shot mode: compute exact probabilities, then sample. ---
        if shots is not None and type in ("probs", "expval"):
            shot_cache_key = (type, "shots", shots, in_axes, arg_shapes, gate_error)
            shot_in_axes = in_axes + (0,)  # shot key batched over axis 0
            shot_args = args + (jax.random.split(key, batch_size),)

            plan = self._jit_cache.get(shot_cache_key)
            if plan is None:
                scalar_args = tuple(
                    self._slice_first(a, ax) if ax is not None else a
                    for a, ax in zip(args, in_axes)
                )
                n_qubits, use_density, n_ops = self._record_metadata(
                    scalar_args, kwargs, obs, type
                )

                # Re-recording inside the closure lets jax.vmap trace the whole
                # batch into one XLA program; the shot key is the extra vmapped
                # argument.
                def _single_execute_shots(*single_args_and_key):
                    *single_args, shot_key = single_args_and_key
                    single_tape = self._record(*single_args, **kwargs)
                    exact_result = simulation.simulate_and_measure(
                        single_tape, n_qubits, "probs", obs, use_density
                    )
                    return simulation.sample_shots(
                        exact_result, n_qubits, type, obs, shots, shot_key
                    )

                batched_fn = eqx.filter_jit(
                    jax.vmap(_single_execute_shots, in_axes=shot_in_axes)
                )
                plan = _BatchPlan(batched_fn, None, n_qubits, use_density, n_ops)
                self._jit_cache[shot_cache_key] = plan

            chunk_size = self._chunk_size(
                shot_cache_key, plan, type, len(obs), batch_size
            )
            # Shot mode never uses the AOT fast path (plain_fn is None).
            return self._dispatch(
                None,
                plan.batched_fn,
                None,
                shot_args,
                shot_in_axes,
                batch_size,
                chunk_size,
            )

        # --- Exact mode: reuse the cached plan or build it on a miss. ---
        cache_kwargs = _make_hashable(
            {k: v for k, v in kwargs.items() if not isinstance(v, jnp.ndarray)}
        )
        cache_key = (type, in_axes, arg_shapes, cache_kwargs, gate_error)

        # The cached ``batched_fn`` (eqx.filter_jit wrapper) is reused across
        # calls including under an outer transform: its ``_single_execute``
        # closure captures only concrete metadata (n_qubits/obs/use_density and
        # non-array kwargs), so it leaks no tracers, and reusing one wrapper
        # lets JAX hit its aval-keyed trace cache instead of re-tracing the
        # circuit every call.  Only the AOT ``plain_fn`` (a compiled executable)
        # is invalid for tracers; its use is gated below by ``in_transform``.
        plan = self._jit_cache.get(cache_key)
        if plan is None:
            plan = self._build_plan(type, obs, args, kwargs, in_axes)
            self._jit_cache[cache_key] = plan

        chunk_size = self._chunk_size(cache_key, plan, type, len(obs), batch_size)
        return self._dispatch(
            ("_aot", cache_key, batch_size),
            plan.batched_fn,
            None if in_transform else plan.plain_fn,
            args,
            in_axes,
            batch_size,
            chunk_size,
        )

    def draw(
        self,
        figure: str = "text",
        args: tuple = (),
        kwargs: Optional[dict] = None,
        **draw_kwargs: Any,
    ) -> Union[str, Any]:
        """Draw the quantum circuit.

        Records the tape by calling the circuit function with the given
        arguments, then renders the resulting gate sequence.

        Args:
            figure: Rendering backend.  One of:

                - ``"text"``  — ASCII art (returned as a ``str``).
                - ``"mpl"``   — Matplotlib figure (returns ``(fig, ax)``).
                - ``"tikz"``  — LaTeX/TikZ code via ``quantikz``
                  (returns a :class:`TikzFigure`).
                - ``"pulse"`` — Pulse schedule plot (returns ``(fig, axes)``).

            args: Positional arguments forwarded to the circuit function
                to record the tape.
            kwargs: Keyword arguments forwarded to the circuit function.
            **draw_kwargs: Extra options forwarded to the rendering backend:

                - ``gate_values`` (bool): Show numeric gate angles instead of
                  symbolic \\theta_i labels.  Default ``False``.
                - ``show_carrier`` (bool): For ``"pulse"`` mode, overlay the
                  carrier-modulated waveform.  Default ``False``.

        Returns:
            Depends on *figure*:

            - ``"text"``  -> ``str``
            - ``"mpl"``   -> ``(matplotlib.figure.Figure, matplotlib.axes.Axes)``
            - ``"tikz"``  -> :class:`TikzFigure`
            - ``"pulse"`` -> ``(matplotlib.figure.Figure, numpy.ndarray)``

        Raises:
            ValueError: If *figure* is not one of the supported modes.
        """
        if figure not in ("text", "mpl", "tikz", "pulse"):
            raise ValueError(
                f"Invalid figure mode: {figure!r}. "
                "Must be 'text', 'mpl', 'tikz', or 'pulse'."
            )

        if kwargs is None:
            kwargs = {}

        if figure == "pulse":
            from qml_essentials.drawing import draw_pulse_schedule

            events = self.pulse_events(*args, **kwargs)
            n_qubits = (
                self._n_qubits
                or max((w for ev in events for w in ev.wires), default=0) + 1
            )
            return draw_pulse_schedule(events, n_qubits, **draw_kwargs)

        tape = self._record(*args, **kwargs)
        n_qubits = self._n_qubits or simulation.infer_n_qubits(tape, [])

        # Filter out noise channels for drawing — they clutter the diagram
        ops = [op for op in tape if not isinstance(op, KrausChannel)]

        if figure == "text":
            return draw_text(ops, n_qubits)
        elif figure == "mpl":
            return draw_mpl(ops, n_qubits, **draw_kwargs)
        else:  # tikz
            return draw_tikz(ops, n_qubits, **draw_kwargs)

__init__(f, n_qubits=None) #

Initialise a Script.

Parameters:

Name Type Description Default
f Callable[..., None]

A function whose body instantiates Operation objects. Signature: f(*args, **kwargs) -> None.

required
n_qubits Optional[int]

Number of qubits. If None, inferred from the operations recorded on the tape.

None
Source code in qml_essentials/script.py
def __init__(self, f: Callable[..., None], n_qubits: Optional[int] = None) -> None:
    """Initialise a Script.

    Args:
        f: A function whose body instantiates ``Operation`` objects.
            Signature: ``f(*args, **kwargs) -> None``.
        n_qubits: Number of qubits.  If ``None``, inferred from the
            operations recorded on the tape.
    """
    self.f = f
    self._n_qubits = n_qubits
    self._jit_cache: dict = {}  # keyed on (type, in_axes, arg_shapes, gateError)

draw(figure='text', args=(), kwargs=None, **draw_kwargs) #

Draw the quantum circuit.

Records the tape by calling the circuit function with the given arguments, then renders the resulting gate sequence.

Parameters:

Name Type Description Default
figure str

Rendering backend. One of:

  • "text" — ASCII art (returned as a str).
  • "mpl" — Matplotlib figure (returns (fig, ax)).
  • "tikz" — LaTeX/TikZ code via quantikz (returns a :class:TikzFigure).
  • "pulse" — Pulse schedule plot (returns (fig, axes)).
'text'
args tuple

Positional arguments forwarded to the circuit function to record the tape.

()
kwargs Optional[dict]

Keyword arguments forwarded to the circuit function.

None
**draw_kwargs Any

Extra options forwarded to the rendering backend:

  • gate_values (bool): Show numeric gate angles instead of symbolic \theta_i labels. Default False.
  • show_carrier (bool): For "pulse" mode, overlay the carrier-modulated waveform. Default False.
{}

Returns:

Type Description
Union[str, Any]

Depends on figure:

Union[str, Any]
  • "text" -> str
Union[str, Any]
  • "mpl" -> (matplotlib.figure.Figure, matplotlib.axes.Axes)
Union[str, Any]
  • "tikz" -> :class:TikzFigure
Union[str, Any]
  • "pulse" -> (matplotlib.figure.Figure, numpy.ndarray)

Raises:

Type Description
ValueError

If figure is not one of the supported modes.

Source code in qml_essentials/script.py
def draw(
    self,
    figure: str = "text",
    args: tuple = (),
    kwargs: Optional[dict] = None,
    **draw_kwargs: Any,
) -> Union[str, Any]:
    """Draw the quantum circuit.

    Records the tape by calling the circuit function with the given
    arguments, then renders the resulting gate sequence.

    Args:
        figure: Rendering backend.  One of:

            - ``"text"``  — ASCII art (returned as a ``str``).
            - ``"mpl"``   — Matplotlib figure (returns ``(fig, ax)``).
            - ``"tikz"``  — LaTeX/TikZ code via ``quantikz``
              (returns a :class:`TikzFigure`).
            - ``"pulse"`` — Pulse schedule plot (returns ``(fig, axes)``).

        args: Positional arguments forwarded to the circuit function
            to record the tape.
        kwargs: Keyword arguments forwarded to the circuit function.
        **draw_kwargs: Extra options forwarded to the rendering backend:

            - ``gate_values`` (bool): Show numeric gate angles instead of
              symbolic \\theta_i labels.  Default ``False``.
            - ``show_carrier`` (bool): For ``"pulse"`` mode, overlay the
              carrier-modulated waveform.  Default ``False``.

    Returns:
        Depends on *figure*:

        - ``"text"``  -> ``str``
        - ``"mpl"``   -> ``(matplotlib.figure.Figure, matplotlib.axes.Axes)``
        - ``"tikz"``  -> :class:`TikzFigure`
        - ``"pulse"`` -> ``(matplotlib.figure.Figure, numpy.ndarray)``

    Raises:
        ValueError: If *figure* is not one of the supported modes.
    """
    if figure not in ("text", "mpl", "tikz", "pulse"):
        raise ValueError(
            f"Invalid figure mode: {figure!r}. "
            "Must be 'text', 'mpl', 'tikz', or 'pulse'."
        )

    if kwargs is None:
        kwargs = {}

    if figure == "pulse":
        from qml_essentials.drawing import draw_pulse_schedule

        events = self.pulse_events(*args, **kwargs)
        n_qubits = (
            self._n_qubits
            or max((w for ev in events for w in ev.wires), default=0) + 1
        )
        return draw_pulse_schedule(events, n_qubits, **draw_kwargs)

    tape = self._record(*args, **kwargs)
    n_qubits = self._n_qubits or simulation.infer_n_qubits(tape, [])

    # Filter out noise channels for drawing — they clutter the diagram
    ops = [op for op in tape if not isinstance(op, KrausChannel)]

    if figure == "text":
        return draw_text(ops, n_qubits)
    elif figure == "mpl":
        return draw_mpl(ops, n_qubits, **draw_kwargs)
    else:  # tikz
        return draw_tikz(ops, n_qubits, **draw_kwargs)

execute(type='expval', obs=None, *, args=(), kwargs=None, in_axes=None, shots=None, key=None) #

Execute the circuit and return measurement results.

Parameters:

Name Type Description Default
type str

Measurement type. One of:

  • "expval" — expectation value \langle\psi|O|\psi\rangle / Tr(O\rho ) for each observable in obs.
  • "probs" — probability vector of shape (2**n,).
  • "state" — raw statevector of shape (2**n,).
  • "density" — full density matrix of shape (2**n, 2**n).
'expval'
obs Optional[List[Operation]]

Observables required when type is "expval".

None
args tuple

Positional arguments forwarded to the circuit function f.

()
kwargs Optional[dict]

Keyword arguments forwarded to f.

None
in_axes Optional[Tuple]

Batch axes for each element of args, following the same convention as jax.vmap:

  • An integer selects that axis of the corresponding array as the batch dimension.
  • None broadcasts the argument (no batching).

When provided, :meth:execute calls jax.vmap over the pure simulation kernel and returns results with a leading batch dimension.

None
shots Optional[int]

Number of measurement shots for stochastic sampling. If None (default), exact analytic results are returned. Only supported for "probs" and "expval" measurement types.

None
key Optional[ndarray]

JAX PRNG key for shot sampling. If None and shots is set, a default key jax.random.PRNGKey(0) is used.

None

Returns:

Type Description
ndarray

Without in_axes: shape determined by type.

ndarray

With in_axes: shape (B, ...) with a leading batch dimension.

Source code in qml_essentials/script.py
def execute(
    self,
    type: str = "expval",
    obs: Optional[List[Operation]] = None,
    *,
    args: tuple = (),
    kwargs: Optional[dict] = None,
    in_axes: Optional[Tuple] = None,
    shots: Optional[int] = None,
    key: Optional[jnp.ndarray] = None,
) -> jnp.ndarray:
    """Execute the circuit and return measurement results.

    Args:
        type: Measurement type.  One of:

            - ``"expval"``  — expectation value
                \\langle\\psi|O|\\psi\\rangle / Tr(O\\rho )
                for each observable in *obs*.
            - ``"probs"``   — probability vector of shape ``(2**n,)``.
            - ``"state"``   — raw statevector of shape ``(2**n,)``.
            - ``"density"`` — full density matrix of shape
              ``(2**n, 2**n)``.

        obs: Observables required when type is ``"expval"``.
        args: Positional arguments forwarded to the circuit function f.
        kwargs: Keyword arguments forwarded to f.
        in_axes: Batch axes for each element of *args*, following the same
            convention as ``jax.vmap``:

            - An integer selects that axis of the corresponding array as
              the batch dimension.
            - ``None`` broadcasts the argument (no batching).

            When provided, :meth:`execute` calls ``jax.vmap`` over the
            pure simulation kernel and returns results with a leading
            batch dimension.
        shots: Number of measurement shots for stochastic sampling.
            If ``None`` (default), exact analytic results are returned.
            Only supported for ``"probs"`` and ``"expval"`` measurement
            types.
        key: JAX PRNG key for shot sampling.  If ``None`` and *shots*
            is set, a default key ``jax.random.PRNGKey(0)`` is used.

    Returns:
        Without in_axes: shape determined by type.
        With in_axes: shape ``(B, ...)`` with a leading batch dimension.
    """
    if obs is None:
        obs = []
    if kwargs is None:
        kwargs = {}
    if shots is not None and key is None:
        key = jax.random.PRNGKey(0)

    # Split single/ parallel execution
    # TODO: we might want to unify the n_qubit stuff such that we can eliminate
    # the parameter to this method entirely
    if in_axes is not None:
        return self._execute_batched(
            type=type,
            obs=obs,
            args=args,
            kwargs=kwargs,
            in_axes=in_axes,
            shots=shots,
            key=key,
        )
    else:
        tape = self._record(*args, **kwargs)
        n_qubits = self._n_qubits or simulation.infer_n_qubits(tape, obs)

        use_density = simulation.uses_density(tape, type)

        return simulation.simulate_and_measure(
            tape,
            n_qubits,
            type,
            obs,
            use_density,
            shots=shots,
            key=key,
        )

pulse_events(*args, **kwargs) #

Run the circuit and collect pulse events emitted by PulseGates.

Activates both the normal operation tape (so gates execute) and a pulse-event tape that captures :class:~qml_essentials.drawing.PulseEvent objects from leaf pulse gates (RX, RY, RZ, CZ).

Parameters:

Name Type Description Default
*args Any

Forwarded to the circuit function.

()
**kwargs Any

Forwarded to the circuit function.

{}

Returns:

Type Description
list

List of :class:~qml_essentials.drawing.PulseEvent.

Source code in qml_essentials/script.py
def pulse_events(self, *args, **kwargs) -> list:
    """Run the circuit and collect pulse events emitted by PulseGates.

    Activates both the normal operation tape (so gates execute) and
    a pulse-event tape that captures
    :class:`~qml_essentials.drawing.PulseEvent` objects from leaf
    pulse gates (RX, RY, RZ, CZ).

    Args:
        *args (Any): Forwarded to the circuit function.
        **kwargs (Any): Forwarded to the circuit function.

    Returns:
        List of :class:`~qml_essentials.drawing.PulseEvent`.
    """
    with pulse_recording() as events:
        with recording():
            self.f(*args, **kwargs)
    return events

Drawing#

from qml_essentials.drawing import TikzFigure

Wrapper around a quantikz LaTeX string with export helpers.

Source code in qml_essentials/drawing.py
class TikzFigure:
    """Wrapper around a ``quantikz`` LaTeX string with export helpers."""

    def __init__(self, quantikz_str: str):
        self.quantikz_str = quantikz_str

    def __repr__(self):
        return self.quantikz_str

    def __str__(self):
        return self.quantikz_str

    def wrap_figure(self) -> str:
        """
        Wraps the quantikz string in a LaTeX figure environment.

        Returns:
            str: A formatted LaTeX string representing the TikZ figure containing
            the quantum circuit diagram.
        """
        return f"""
\\begin{{figure}}
    \\centering
    \\begin{{tikzpicture}}
        \\node[scale=0.85] {{
            \\begin{{quantikz}}
                {self.quantikz_str}
            \\end{{quantikz}}
        }};
    \\end{{tikzpicture}}
\\end{{figure}}"""

    def export(
        self, destination: str, full_document: bool = False, mode: str = "w"
    ) -> None:
        """
        Export a LaTeX document with a quantum circuit in stick notation.

        Parameters
        ----------
        quantikz_strs : str or list[str]
            LaTeX string for the quantum circuit or a list of LaTeX strings.
        destination : str
            Path to the destination file.
        """
        if full_document:
            latex_code = f"""
\\documentclass{{article}}
\\usepackage{{quantikz}}
\\usepackage{{tikz}}
\\usetikzlibrary{{quantikz2}}
\\usepackage{{quantikz}}
\\usepackage[a3paper, landscape, margin=0.5cm]{{geometry}}
\\begin{{document}}
{self.wrap_figure()}
\\end{{document}}"""
        else:
            latex_code = self.quantikz_str + "\n"

        with open(destination, mode) as f:
            f.write(latex_code)

export(destination, full_document=False, mode='w') #

Export a LaTeX document with a quantum circuit in stick notation.

Parameters#

quantikz_strs : str or list[str] LaTeX string for the quantum circuit or a list of LaTeX strings. destination : str Path to the destination file.

Source code in qml_essentials/drawing.py
    def export(
        self, destination: str, full_document: bool = False, mode: str = "w"
    ) -> None:
        """
        Export a LaTeX document with a quantum circuit in stick notation.

        Parameters
        ----------
        quantikz_strs : str or list[str]
            LaTeX string for the quantum circuit or a list of LaTeX strings.
        destination : str
            Path to the destination file.
        """
        if full_document:
            latex_code = f"""
\\documentclass{{article}}
\\usepackage{{quantikz}}
\\usepackage{{tikz}}
\\usetikzlibrary{{quantikz2}}
\\usepackage{{quantikz}}
\\usepackage[a3paper, landscape, margin=0.5cm]{{geometry}}
\\begin{{document}}
{self.wrap_figure()}
\\end{{document}}"""
        else:
            latex_code = self.quantikz_str + "\n"

        with open(destination, mode) as f:
            f.write(latex_code)

wrap_figure() #

Wraps the quantikz string in a LaTeX figure environment.

Returns:

Name Type Description
str str

A formatted LaTeX string representing the TikZ figure containing

str

the quantum circuit diagram.

Source code in qml_essentials/drawing.py
    def wrap_figure(self) -> str:
        """
        Wraps the quantikz string in a LaTeX figure environment.

        Returns:
            str: A formatted LaTeX string representing the TikZ figure containing
            the quantum circuit diagram.
        """
        return f"""
\\begin{{figure}}
    \\centering
    \\begin{{tikzpicture}}
        \\node[scale=0.85] {{
            \\begin{{quantikz}}
                {self.quantikz_str}
            \\end{{quantikz}}
        }};
    \\end{{tikzpicture}}
\\end{{figure}}"""
from qml_essentials.drawing import PulseEvent

Single pulse applied to one or more wires.

Attributes:

Name Type Description
gate str

Gate label, e.g. "RX", "CZ".

wires List[int]

Target qubit wire(s).

envelope_fn Any

Pure envelope function (p, t, t_c) -> amplitude.

envelope_params Any

Envelope-shape parameters (excluding w and t).

w float

Rotation angle passed to the gate.

duration float

Pulse duration (evolution time).

carrier_phase float

Phase offset for the carrier cosine.

parent Optional[str]

Optional high-level gate name that decomposed into this event.

Source code in qml_essentials/drawing.py
@dataclass
class PulseEvent:
    """Single pulse applied to one or more wires.

    Attributes:
        gate: Gate label, e.g. ``"RX"``, ``"CZ"``.
        wires: Target qubit wire(s).
        envelope_fn: Pure envelope function ``(p, t, t_c) -> amplitude``.
        envelope_params: Envelope-shape parameters (excluding ``w`` and ``t``).
        w: Rotation angle passed to the gate.
        duration: Pulse duration (evolution time).
        carrier_phase: Phase offset for the carrier cosine.
        parent: Optional high-level gate name that decomposed into this event.
    """

    gate: str
    wires: List[int]
    envelope_fn: Any  # (p, t, t_c) -> scalar
    envelope_params: Any  # jnp array of envelope shape params
    w: float  # rotation angle
    duration: float  # evolution time
    carrier_phase: float = 0.0  # phi_c in cos(omega_c * t + phi_c)
    parent: Optional[str] = None  # composite gate that owns this pulse

Tape#

from qml_essentials.tape import recording, pulse_recording

Context manager that creates a fresh tape for recording operations.

Operations instantiated inside this block will be appended to the returned tape list (via :func:active_tape). Nesting is supported: each with recording() pushes a new tape onto the per-thread stack, and the previous tape is restored on exit.

Yields:

Type Description
List['Operation']

A new empty list that will be populated with Operation instances.

Source code in qml_essentials/tape.py
@contextmanager
def recording() -> Iterator[List["Operation"]]:
    """Context manager that creates a fresh tape for recording operations.

    Operations instantiated inside this block will be appended to the
    returned tape list (via :func:`active_tape`).  Nesting is supported:
    each ``with recording()`` pushes a new tape onto the per-thread stack,
    and the previous tape is restored on exit.

    Yields:
        A new empty list that will be populated with ``Operation`` instances.
    """
    stack = _tape_stack()
    tape: List["Operation"] = []
    stack.append(tape)
    try:
        yield tape
    finally:
        stack.pop()

Context manager that collects pulse events emitted by PulseGates.

Yields:

Type Description
list

A list that will be populated with

list

class:~qml_essentials.drawing.PulseEvent instances.

Source code in qml_essentials/tape.py
@contextmanager
def pulse_recording() -> Iterator[list]:
    """Context manager that collects pulse events emitted by PulseGates.

    Yields:
        A list that will be populated with
        :class:`~qml_essentials.drawing.PulseEvent` instances.
    """
    stack = _pulse_tape_stack()
    tape: list = []
    stack.append(tape)
    try:
        yield tape
    finally:
        stack.pop()