Skip to content

References

Gates#

Gates is the entry point for applying gates to a circuit. It records the gate on the active tape, attaches any requested noise, and routes the call to either UnitaryGates or PulseGates depending on the pulse keyword. The two backends below, and the matrix-level classes in jaqsi.gateset, are what Gates dispatches to; call them directly only when you need the operation object itself (for observables, .dagger() / .power(), or matrix algebra).

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 jaqsi import Gates

The entry point for applying gates to a circuit.

Call gates as Gates.<Name>(...) inside a circuit function; the call is routed to either UnitaryGates or PulseGates depending on the pulse keyword. Prefer this over calling the backends (UnitaryGates, PulseGates) or the matrix-level classes in :mod:jaqsi.gateset directly, so that the same circuit can run at either level.

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#

pulse : bool, optional Whether to run the gate at pulse level (PulseGates) instead of as an ideal unitary (UnitaryGates). Defaults to False.

Examples#

Gates.RX(w, wires) Gates.RX(w, wires, pulse=True) Gates.RX(w, wires, pulse=True, pulse_params=pulse_params)

Source code in jaqsi/gates.py
class Gates(metaclass=GatesMeta):
    """
    The entry point for applying gates to a circuit.

    Call gates as ``Gates.<Name>(...)`` inside a circuit function; the call is
    routed to either `UnitaryGates` or `PulseGates` depending on the `pulse`
    keyword.  Prefer this over calling the backends (`UnitaryGates`,
    `PulseGates`) or the matrix-level classes in :mod:`jaqsi.gateset` directly,
    so that the same circuit can run at either level.

    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
    ----------
    pulse : bool, optional
        Whether to run the gate at pulse level (`PulseGates`) instead of as an
        ideal unitary (`UnitaryGates`). Defaults to ``False``.

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

    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)

        pulse = kwargs.pop("pulse", False)
        if not isinstance(pulse, bool):
            raise TypeError(f"'pulse' must be a bool, got {type(pulse).__name__}.")

        # Backend selection and kwargs filtering
        allowed_args = [
            "w",
            "wires",
            "phi",
            "theta",
            "omega",
            "noise_params",
            "random_key",
        ]
        if pulse:
            gate_backend = PulseGates
            allowed_args += ["pulse_params"]
        else:
            gate_backend = UnitaryGates

        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 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",
            "RXX",
            "RYY",
            "RZZ",
            "RZX",
            "CRX",
            "CRY",
            "CRZ",
            "CPhase",
        ]

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

    @classmethod
    def is_controlled(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 jaqsi/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 jaqsi.gates import UnitaryGates

Collection of unitary quantum gates with optional noise simulation.

Source code in jaqsi/unitary.py
 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
class UnitaryGates:
    """Collection of unitary quantum gates with optional noise simulation."""

    batch_gate_error = True

    @staticmethod
    def NQubitDepolarizingChannel(p: float, wires: List[int]) -> noise.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:
            noise.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 = gateset.PauliX._matrix
            Y = gateset.PauliY._matrix
            Z = gateset.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 noise.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:
                    noise.BitFlip(bf, wires=wire)

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

                dp = noise_params.get("Depolarizing", 0.0)
                if dp > 0:
                    noise.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"
            )

            # Gates within an ansatz layer all receive the same ``random_key``,
            # so the position on the tape is folded in to decorrelate their
            # draws. It is a Python int at trace time (this runs while the tape
            # is being recorded) and hence constant across the batch.
            tape = op.active_tape()
            position = len(tape) if tape is not None else 0

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

            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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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)
        gateset.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.
        """
        gateset.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.
        """
        gateset.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.
        """
        gateset.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.
        """
        gateset.H(wires=wires)
        UnitaryGates.Noise(wires, 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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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.
    """
    gateset.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 jaqsi/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.
    """
    gateset.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 jaqsi/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.
    """
    gateset.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 jaqsi/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"
        )

        # Gates within an ansatz layer all receive the same ``random_key``,
        # so the position on the tape is folded in to decorrelate their
        # draws. It is a Python int at trace time (this runs while the tape
        # is being recorded) and hence constant across the batch.
        tape = op.active_tape()
        position = len(tape) if tape is not None else 0

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

        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

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 jaqsi/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.
    """
    gateset.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

noise.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 jaqsi/unitary.py
@staticmethod
def NQubitDepolarizingChannel(p: float, wires: List[int]) -> noise.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:
        noise.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 = gateset.PauliX._matrix
        Y = gateset.PauliY._matrix
        Z = gateset.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 noise.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 jaqsi/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:
                noise.BitFlip(bf, wires=wire)

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

            dp = noise_params.get("Depolarizing", 0.0)
            if dp > 0:
                noise.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.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 jaqsi/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)
    gateset.Rot(phi, theta, omega, wires=wires)
    UnitaryGates.Noise(wires, noise_params)

Pulse Gates#

from jaqsi.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 jaqsi/pulses.py
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
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:`~jaqsi.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 jaqsi.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 = Hamiltonian(PulseGates.X, wires=wires)
        H_Y = 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 = Hamiltonian(PulseGates.X, wires=wires)
        H_Y = 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 = 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 = 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 = 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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 = 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 jaqsi/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 = 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 jaqsi/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 jaqsi/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 = Hamiltonian(PulseGates.X, wires=wires)
    H_Y = 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 jaqsi/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 jaqsi/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 = Hamiltonian(PulseGates.X, wires=wires)
    H_Y = 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 jaqsi/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 jaqsi/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 = 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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/pulses.py
def __repr__(self) -> str:
    """Return repr string (gate name)."""
    return self.name

__str__() #

Return string representation (gate name).

Source code in jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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.
            #
            # Note the envelope center convention: ``t_c = t / 2`` uses the
            # running integration variable ``t``, so ``envelope_fn`` evaluates
            # to a monotone decay over ``[0, t_final]`` rather than a bump
            # centered at the pulse midpoint. The calibrated defaults are fit
            # around this exact form.
            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 jaqsi/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 jaqsi/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.
        #
        # Note the envelope center convention: ``t_c = t / 2`` uses the
        # running integration variable ``t``, so ``envelope_fn`` evaluates
        # to a monotone decay over ``[0, t_final]`` rather than a bump
        # centered at the pulse midpoint. The calibrated defaults are fit
        # around this exact form.
        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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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``: the RWA is applied, dropping the fast
    # counter-rotating terms (much faster to integrate).
    # Set to ``False`` for the exact dynamics, retaining the carrier
    # and counter-rotating terms.
    # 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.
        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))

    @classmethod
    def update_params(cls, path=None):
        """Load QOC-tuned pulse parameters for the active envelope.

        Defaults to the ``qoc_results_<envelope>.csv`` shipped with the
        package; pass ``path`` to load a file produced by a custom QOC run.
        """
        if path is None:
            path = str(
                resources.files(__package__).joinpath(
                    f"qoc_results_{cls.get_envelope()}.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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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.
    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 jaqsi/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 jaqsi/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 jaqsi/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,
    )

update_params(path=None) classmethod #

Load QOC-tuned pulse parameters for the active envelope.

Defaults to the qoc_results_<envelope>.csv shipped with the package; pass path to load a file produced by a custom QOC run.

Source code in jaqsi/pulses.py
@classmethod
def update_params(cls, path=None):
    """Load QOC-tuned pulse parameters for the active envelope.

    Defaults to the ``qoc_results_<envelope>.csv`` shipped with the
    package; pass ``path`` to load a file produced by a custom QOC run.
    """
    if path is None:
        path = str(
            resources.files(__package__).joinpath(
                f"qoc_results_{cls.get_envelope()}.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}")

Operations#

from jaqsi.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 jaqsi/operations.py
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
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.  Used e.g. by Pauli-Clifford transforms 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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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. Used e.g. by Pauli-Clifford transforms 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 jaqsi/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.  Used e.g. by Pauli-Clifford transforms 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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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:`jaqsi.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 jaqsi.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 jaqsi/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 jaqsi/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:jaqsi.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 jaqsi/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:`jaqsi.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 jaqsi.evolution import Evolution  # deferred: circular import

    return Evolution.evolve(self, name=name, **odeint_kwargs)

Parametrized Hamiltonian#

from jaqsi.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 jaqsi/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:`jaqsi.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 jaqsi.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 jaqsi/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 jaqsi/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 jaqsi/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:jaqsi.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 jaqsi/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:`jaqsi.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 jaqsi.evolution import Evolution  # deferred: circular import

    return Evolution.evolve(self, name=name, **odeint_kwargs)

Pauli Rotation#

from jaqsi.gateset 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 jaqsi/gateset.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.
        """
        self.theta = theta
        self.pauli_word = pauli_word

        P = _pauli_tensor(pauli_word)
        mat = _rot_matrix(theta, 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.
        """
        P = _pauli_tensor(self.pauli_word)
        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 jaqsi/gateset.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.
    """
    self.theta = theta
    self.pauli_word = pauli_word

    P = _pauli_tensor(pauli_word)
    mat = _rot_matrix(theta, 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 jaqsi/gateset.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.
    """
    P = _pauli_tensor(self.pauli_word)
    return Hermitian(matrix=P, wires=self.wires, record=False)

Paulis#

from jaqsi.paulis import PauliWord, pauli_decompose, state_expectation

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 backs Pauli-Clifford circuit transforms and Fourier-tree algorithms built on top of it.

All operations use NumPy (integer arithmetic), not JAX — this is symbolic bookkeeping, not numeric computation.

Source code in jaqsi/paulis.py
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 backs Pauli-Clifford circuit transforms and
    Fourier-tree algorithms built on top of it.

    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)
        if op.name in _NAME_TO_PAULI_LABEL:
            return cls.from_pauli_string(
                _NAME_TO_PAULI_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 expectation(self, state: np.ndarray) -> complex:
        r"""Return ``\langle\psi|P|\psi\rangle`` for an arbitrary statevector.

        Applies the single-qubit Pauli factors to the reshaped state via tensor
        contraction (``O(n 2^n)``) instead of forming the dense
        ``2^n \times 2^n`` operator.  The real part is exact for a Hermitian
        Pauli word.

        Args:
            state: Statevector of length ``2**n_qubits`` (qubit 0 leftmost).

        Returns:
            The expectation value ``\langle\psi|P|\psi\rangle``.
        """
        n = self.n_qubits
        psi = np.asarray(state, dtype=complex).reshape((2,) * n)
        out = psi
        for q, ch in enumerate(self.to_pauli_string()):
            if ch == "I":
                continue
            out = np.moveaxis(np.tensordot(_SQ_NP[ch], out, axes=(1, q)), 0, q)
        val = np.vdot(psi.reshape(-1), out.reshape(-1))
        return self.leading_phase() * complex(val)

    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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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

expectation(state) #

Return \langle\psi|P|\psi\rangle for an arbitrary statevector.

Applies the single-qubit Pauli factors to the reshaped state via tensor contraction (O(n 2^n)) instead of forming the dense 2^n \times 2^n operator. The real part is exact for a Hermitian Pauli word.

Parameters:

Name Type Description Default
state ndarray

Statevector of length 2**n_qubits (qubit 0 leftmost).

required

Returns:

Type Description
complex

The expectation value \langle\psi|P|\psi\rangle.

Source code in jaqsi/paulis.py
def expectation(self, state: np.ndarray) -> complex:
    r"""Return ``\langle\psi|P|\psi\rangle`` for an arbitrary statevector.

    Applies the single-qubit Pauli factors to the reshaped state via tensor
    contraction (``O(n 2^n)``) instead of forming the dense
    ``2^n \times 2^n`` operator.  The real part is exact for a Hermitian
    Pauli word.

    Args:
        state: Statevector of length ``2**n_qubits`` (qubit 0 leftmost).

    Returns:
        The expectation value ``\langle\psi|P|\psi\rangle``.
    """
    n = self.n_qubits
    psi = np.asarray(state, dtype=complex).reshape((2,) * n)
    out = psi
    for q, ch in enumerate(self.to_pauli_string()):
        if ch == "I":
            continue
        out = np.moveaxis(np.tensordot(_SQ_NP[ch], out, axes=(1, q)), 0, q)
    val = np.vdot(psi.reshape(-1), out.reshape(-1))
    return self.leading_phase() * complex(val)

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 jaqsi/paulis.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 jaqsi/paulis.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)
    if op.name in _NAME_TO_PAULI_LABEL:
        return cls.from_pauli_string(
            _NAME_TO_PAULI_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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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 jaqsi/paulis.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)

Decompose a Hermitian matrix into a sum of Pauli tensor products.

For an n-qubit matrix (2**n x 2**n), returns the dominant Pauli term (the one with the largest absolute coefficient), wrapped as an :class:Operation. This is sufficient for the Fourier-tree algorithm which only needs the single non-zero Pauli term produced by Clifford conjugation of a Pauli operator.

The decomposition uses the trace formula: c_P = Tr(P · M) / 2**n

Parameters:

Name Type Description Default
matrix ndarray

A (2**n, 2**n) Hermitian matrix.

required
wire_order Optional[List[int]]

Optional list of wire indices. If None, defaults to [0, 1, ..., n-1].

None

Returns:

Name Type Description

A tuple (coeff, op) where coeff is the complex coefficient and

op is the Pauli :class:Operation (PauliX, PauliY, PauliZ, I, or

a

class:Hermitian for multi-qubit tensor products).

Source code in jaqsi/paulis.py
def pauli_decompose(matrix: jnp.ndarray, wire_order: Optional[List[int]] = None):
    r"""Decompose a Hermitian matrix into a sum of Pauli tensor products.

    For an n-qubit matrix (``2**n x 2**n``), returns the dominant Pauli
    term (the one with the largest absolute coefficient), wrapped as an
    :class:`Operation`.  This is sufficient for the Fourier-tree algorithm
    which only needs the single non-zero Pauli term produced by Clifford
    conjugation of a Pauli operator.

    The decomposition uses the trace formula:
    ``c_P = Tr(P · M) / 2**n``

    Args:
        matrix: A ``(2**n, 2**n)`` Hermitian matrix.
        wire_order: Optional list of wire indices.  If ``None``, defaults
            to ``[0, 1, ..., n-1]``.

    Returns:
        A tuple ``(coeff, op)`` where *coeff* is the complex coefficient and
        *op* is the Pauli :class:`Operation` (PauliX, PauliY, PauliZ, I, or
        a :class:`Hermitian` for multi-qubit tensor products).
    """
    dim = matrix.shape[0]
    n_qubits = int(jnp.round(jnp.log2(dim)))

    if wire_order is None:
        wire_order = list(range(n_qubits))

    best_coeff, pauli_label = _dominant_pauli_label(matrix)
    label_to_idx = {label: i for i, label in enumerate(_PAULI_LABELS)}

    # Build the operation for the dominant term
    if sum(1 for ch in pauli_label if ch != "I") <= 1:
        # Single-qubit Pauli on one wire (or all-identity)
        for q, ch in enumerate(pauli_label):
            if ch != "I":
                result_op = _PAULI_CLASSES[label_to_idx[ch]](
                    wires=wire_order[q], record=False
                )
                result_op._pauli_label = ch
                return best_coeff, result_op
        result_op = Id(wires=wire_order[0], record=False)
        result_op._pauli_label = "I" * n_qubits
        return best_coeff, result_op
    else:
        # Multi-qubit tensor product -> Hermitian with pauli label attached
        P = _pauli_tensor(pauli_label)
        result_op = Hermitian(matrix=P, wires=wire_order, record=False)
        result_op._pauli_label = pauli_label
        return best_coeff, result_op

Return \langle\psi|O|\psi\rangle for a Pauli observable and statevector.

Parameters:

Name Type Description Default
obs Union[str, PauliWord]

The observable, either a :class:PauliWord or a bare Pauli string over {'I', 'X', 'Y', 'Z'} (qubit 0 leftmost).

required
state ndarray

Statevector of length 2**n_qubits.

required

Returns:

Type Description
complex

The expectation value \langle\psi|O|\psi\rangle.

Source code in jaqsi/paulis.py
def state_expectation(obs: Union[str, PauliWord], state: np.ndarray) -> complex:
    r"""Return ``\langle\psi|O|\psi\rangle`` for a Pauli observable and statevector.

    Args:
        obs: The observable, either a :class:`PauliWord` or a bare Pauli string
            over ``{'I', 'X', 'Y', 'Z'}`` (qubit 0 leftmost).
        state: Statevector of length ``2**n_qubits``.

    Returns:
        The expectation value ``\langle\psi|O|\psi\rangle``.
    """
    if isinstance(obs, str):
        obs = PauliWord.from_pauli_string(obs, list(range(len(obs))), len(obs))
    return obs.expectation(state)

Noise#

from jaqsi.noise import KrausChannel, BitFlip, DepolarizingChannel, ThermalRelaxationError

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 jaqsi/noise.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 jaqsi/noise.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 jaqsi/noise.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 jaqsi/noise.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 jaqsi/noise.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

Bases: KrausChannel

Generic Kraus channel from a user-supplied list of Kraus operators.

This replaces PennyLane's qml.QubitChannel and accepts an arbitrary set of Kraus matrices satisfying \sigma_k K_k\dagger K_k = I.

Example::

kraus_ops = [jnp.sqrt(0.9) * jnp.eye(2), jnp.sqrt(0.1) * PauliX._matrix]
QubitChannel(kraus_ops, wires=0)
Source code in jaqsi/noise.py
class QubitChannel(KrausChannel):
    """Generic Kraus channel from a user-supplied list of Kraus operators.

    This replaces PennyLane's ``qml.QubitChannel`` and accepts an arbitrary set
    of Kraus matrices satisfying \\sigma_k K_k\\dagger K_k = I.

    Example::

        kraus_ops = [jnp.sqrt(0.9) * jnp.eye(2), jnp.sqrt(0.1) * PauliX._matrix]
        QubitChannel(kraus_ops, wires=0)
    """

    def __init__(
        self, kraus_ops: List[jnp.ndarray], wires: Union[int, List[int]] = 0
    ) -> None:
        """Initialise a generic Kraus channel.

        Args:
            kraus_ops: List of Kraus matrices.  Each must be a square 2D array
                of dimension ``2**k x 2**k`` where k = ``len(wires)``.
            wires: Qubit index or list of qubit indices this channel acts on.
        """
        self._kraus_ops = [jnp.asarray(K, dtype=cdtype()) for K in kraus_ops]
        super().__init__(wires=wires)

    def kraus_matrices(self) -> List[jnp.ndarray]:
        """Return the stored Kraus operators.

        Returns:
            List of Kraus operator matrices.
        """
        return self._kraus_ops

__init__(kraus_ops, wires=0) #

Initialise a generic Kraus channel.

Parameters:

Name Type Description Default
kraus_ops List[ndarray]

List of Kraus matrices. Each must be a square 2D array of dimension 2**k x 2**k where k = len(wires).

required
wires Union[int, List[int]]

Qubit index or list of qubit indices this channel acts on.

0
Source code in jaqsi/noise.py
def __init__(
    self, kraus_ops: List[jnp.ndarray], wires: Union[int, List[int]] = 0
) -> None:
    """Initialise a generic Kraus channel.

    Args:
        kraus_ops: List of Kraus matrices.  Each must be a square 2D array
            of dimension ``2**k x 2**k`` where k = ``len(wires)``.
        wires: Qubit index or list of qubit indices this channel acts on.
    """
    self._kraus_ops = [jnp.asarray(K, dtype=cdtype()) for K in kraus_ops]
    super().__init__(wires=wires)

kraus_matrices() #

Return the stored Kraus operators.

Returns:

Type Description
List[ndarray]

List of Kraus operator matrices.

Source code in jaqsi/noise.py
def kraus_matrices(self) -> List[jnp.ndarray]:
    """Return the stored Kraus operators.

    Returns:
        List of Kraus operator matrices.
    """
    return self._kraus_ops

Math#

from jaqsi.math import quantum_fisher_information, fubini_study_metric, fidelity, trace_distance, phase_difference, partial_trace, marginalize_probs, logm_v

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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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)

Partial trace of a density matrix, keeping only the specified qubits.

Supports both 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
rho ndarray

Density matrix of shape (2**n, 2**n) or (B, 2**n, 2**n).

required
n_qubits int

Total number of qubits.

required
keep List[int]

List of qubit indices to keep (0-indexed).

required

Returns:

Type Description
ndarray

Reduced density matrix of shape (2**k, 2**k) or (B, 2**k, 2**k)

ndarray

where k = len(keep).

Source code in jaqsi/math.py
def partial_trace(
    rho: jnp.ndarray,
    n_qubits: int,
    keep: List[int],
) -> jnp.ndarray:
    """Partial trace of a density matrix, keeping only the specified qubits.

    Supports both single density matrices of shape ``(2**n, 2**n)`` and
    batched density matrices of shape ``(B, 2**n, 2**n)``.

    Args:
        rho: Density matrix of shape ``(2**n, 2**n)`` or ``(B, 2**n, 2**n)``.
        n_qubits: Total number of qubits.
        keep: List of qubit indices to *keep* (0-indexed).

    Returns:
        Reduced density matrix of shape ``(2**k, 2**k)`` or ``(B, 2**k, 2**k)``
        where *k* = ``len(keep)``.
    """

    dim = 2**n_qubits
    if rho.shape == (dim, dim):
        return _partial_trace_single(rho, n_qubits, keep)
    # Batched: shape (B, dim, dim)
    return jax.vmap(lambda r: _partial_trace_single(r, n_qubits, keep))(rho)

Marginalize a probability vector to keep only the specified qubits.

Supports both single probability vectors of shape (2**n,) and batched vectors of shape (B, 2**n).

Parameters:

Name Type Description Default
probs ndarray

Probability vector of shape (2**n,) or (B, 2**n).

required
n_qubits int

Total number of qubits.

required
keep Tuple[int]

List of qubit indices to keep (0-indexed).

required

Returns:

Type Description
ndarray

Marginalized probability vector of shape (2**k,) or (B, 2**k)

ndarray

where k = len(keep).

Source code in jaqsi/math.py
def marginalize_probs(
    probs: jnp.ndarray,
    n_qubits: int,
    keep: Tuple[int],
) -> jnp.ndarray:
    """Marginalize a probability vector to keep only the specified qubits.

    Supports both single probability vectors of shape ``(2**n,)`` and
    batched vectors of shape ``(B, 2**n)``.

    Args:
        probs: Probability vector of shape ``(2**n,)`` or ``(B, 2**n)``.
        n_qubits: Total number of qubits.
        keep: List of qubit indices to *keep* (0-indexed).

    Returns:
        Marginalized probability vector of shape ``(2**k,)`` or ``(B, 2**k)``
        where *k* = ``len(keep)``.
    """

    dim = 2**n_qubits
    trace_out = tuple(q for q in range(n_qubits - 1, -1, -1) if q not in keep)
    target_shape = (2,) * n_qubits

    return jax.vmap(lambda p: _marginalize_probs_single(p, target_shape, trace_out))(
        probs.reshape(-1, dim)
    )

Compute the logarithm of a matrix. If the provided matrix has an additional batch dimension, the logarithm of each matrix is computed.

Parameters:

Name Type Description Default
A ndarray

The (potentially batched) matrices of which to compute

required

Returns:

Type Description
ndarray

jnp.ndarray: The log matrices

Source code in jaqsi/math.py
def logm_v(A: jnp.ndarray, **kwargs) -> jnp.ndarray:
    """
    Compute the logarithm of a matrix. If the provided matrix has an additional
    batch dimension, the logarithm of each matrix is computed.

    Args:
        A (jnp.ndarray): The (potentially batched) matrices of which to compute
        the logarithm.

    Returns:
        jnp.ndarray: The log matrices
    """
    # TODO: check warnings
    if len(A.shape) == 2:
        return logm(A, **kwargs)
    elif len(A.shape) == 3:
        AV = jnp.zeros(A.shape, dtype=cdtype())
        for i in range(A.shape[0]):
            AV = AV.at[i].set(logm(A[i], **kwargs))
        return AV
    else:
        raise NotImplementedError("Unsupported shape of input matrix")

Quantum Optimal Control#

from jaqsi.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 jaqsi/qoc.py
 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
2606
2607
2608
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:`jaqsi.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 = 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:
                    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):
                            gateset.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 = [
                    Script(pulse_circuit, n_qubits=wires),
                    Script(pulse_circuit_plus, n_qubits=wires),
                ]
                target_scripts = [
                    Script(target_circuit, n_qubits=wires),
                    Script(target_circuit_plus, n_qubits=wires),
                ]

                d_basis = 2**wires
                pulse_basis_scripts = [
                    Script(_with_basis_prep(pulse_circuit, k, wires), n_qubits=wires)
                    for k in range(d_basis)
                ]
                target_basis_scripts = [
                    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. ``gateset.H``/``gateset.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, pulse=True),
                lambda w: gateset.RX(w, wires=0),
            ),
            "RY": _make_gate_pair(
                lambda w, pp: Gates.RY(w, 0, pulse_params=pp, pulse=True),
                lambda w: gateset.RY(w, wires=0),
            ),
            "RZ": _make_gate_pair(
                lambda w, pp: Gates.RZ(w, 0, pulse_params=pp, pulse=True),
                lambda w: gateset.RZ(w, wires=0),
                prep=lambda w: gateset.H(wires=0),
                post=lambda w: gateset.H(wires=0),
            ),
            "H": _make_gate_pair(
                lambda w, pp: Gates.H(0, pulse_params=pp, pulse=True),
                lambda w: gateset.H(wires=0),
                prep=lambda w: gateset.RY(w, wires=0),
            ),
            "Rot": _make_gate_pair(
                lambda w, pp: Gates.Rot(
                    w, w * 2, w * 3, 0, pulse_params=pp, pulse=True
                ),
                lambda w: gateset.Rot(w, w * 2, w * 3, wires=0),
                prep=lambda w: gateset.H(wires=0),
            ),
            "CX": _make_gate_pair(
                lambda w, pp: Gates.CX(
                    wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CX(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: gateset.RY(w, wires=0),
                    lambda w: gateset.H(wires=1),
                ),
            ),
            "CY": _make_gate_pair(
                lambda w, pp: Gates.CY(
                    wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CY(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: gateset.RX(w, wires=0),
                    lambda w: gateset.H(wires=1),
                ),
            ),
            "CZ": _make_gate_pair(
                lambda w, pp: Gates.CZ(
                    wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CZ(wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: gateset.RY(w, wires=0),
                    lambda w: gateset.H(wires=1),
                ),
            ),
            "CRX": _make_gate_pair(
                lambda w, pp: Gates.CRX(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CRX(w, wires=[0, 1]),
                prep=lambda w: gateset.H(wires=0),
            ),
            "CRY": _make_gate_pair(
                lambda w, pp: Gates.CRY(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CRY(w, wires=[0, 1]),
                prep=lambda w: gateset.H(wires=0),
            ),
            "CRZ": _make_gate_pair(
                lambda w, pp: Gates.CRZ(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CRZ(w, wires=[0, 1]),
                prep=_chain_gate_stages(
                    lambda w: gateset.H(wires=0),
                    lambda w: gateset.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.
        ``gateset.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, pulse=True),
                lambda w: gateset.RX(w, wires=0),
            ),
            "RY": _make_gate_pair(
                lambda w, pp: Gates.RY(w, wires=0, pulse_params=pp, pulse=True),
                lambda w: gateset.RY(w, wires=0),
            ),
            "RZ": _make_gate_pair(
                lambda w, pp: Gates.RZ(w, wires=0, pulse_params=pp, pulse=True),
                lambda w: gateset.RZ(w, wires=0),
            ),
            "H": _make_gate_pair(
                lambda w, pp: Gates.H(0, pulse_params=pp, pulse=True),
                lambda w: gateset.H(wires=0),
            ),
            "CZ": _make_gate_pair(
                lambda w, pp: Gates.CZ(
                    wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CZ(wires=[0, 1]),
            ),
            "CX": _make_gate_pair(
                lambda w, pp: Gates.CX(
                    wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CX(wires=[0, 1]),
            ),
            "CRX": _make_gate_pair(
                lambda w, pp: Gates.CRX(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CRX(w, wires=[0, 1]),
            ),
            "CRY": _make_gate_pair(
                lambda w, pp: Gates.CRY(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.CRY(w, wires=[0, 1]),
            ),
            "CRZ": _make_gate_pair(
                lambda w, pp: Gates.CRZ(
                    w, wires=[0, 1], pulse_params=pp, pulse=True
                ),
                lambda w: gateset.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):
            gateset.H(wires=0)
            gateset.H(wires=1)
            Gates.CPhase(w, wires=[0, 1], pulse_params=pulse_params, pulse=True)

        def target_circuit(w):
            gateset.H(wires=0)
            gateset.H(wires=1)
            gateset.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
                ``{file_dir}/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
            os.makedirs(self.file_dir, exist_ok=True)
            with open(os.path.join(self.file_dir, "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 = 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:
                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 = [
                Script(_with_basis_prep(pulse_circuit, k, n_wires), n_qubits=n_wires)
                for k in range(d_basis)
            ]
            target_basis_scripts = [
                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 jaqsi/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 jaqsi/qoc.py
def create_CPhase(self):
    """Create pulse and target circuits for the CPhase gate."""

    def pulse_circuit(w, pulse_params):
        gateset.H(wires=0)
        gateset.H(wires=1)
        Gates.CPhase(w, wires=[0, 1], pulse_params=pulse_params, pulse=True)

    def target_circuit(w):
        gateset.H(wires=0)
        gateset.H(wires=1)
        gateset.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 jaqsi/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):
                        gateset.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 = [
                Script(pulse_circuit, n_qubits=wires),
                Script(pulse_circuit_plus, n_qubits=wires),
            ]
            target_scripts = [
                Script(target_circuit, n_qubits=wires),
                Script(target_circuit_plus, n_qubits=wires),
            ]

            d_basis = 2**wires
            pulse_basis_scripts = [
                Script(_with_basis_prep(pulse_circuit, k, wires), n_qubits=wires)
                for k in range(d_basis)
            ]
            target_basis_scripts = [
                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 {file_dir}/qoc_logs.csv.

required
Source code in jaqsi/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
            ``{file_dir}/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
        os.makedirs(self.file_dir, exist_ok=True)
        with open(os.path.join(self.file_dir, "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 jaqsi/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 = [
            Script(_with_basis_prep(pulse_circuit, k, n_wires), n_qubits=n_wires)
            for k in range(d_basis)
        ]
        target_basis_scripts = [
            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 jaqsi/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 jaqsi/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 jaqsi/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:jaqsi.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 jaqsi/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:`jaqsi.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 = 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:
                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 jaqsi/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 jaqsi.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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi import Evolution
Source code in jaqsi/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 jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.script import Script

Circuit container and executor backed by pure JAX kernels.

Script takes a callable f representing a quantum circuit. Within f, :class:~jaqsi.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:jaqsi.simulation and the memory-estimation/chunking helpers in :mod:jaqsi.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 jaqsi/script.py
 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
class Script:
    """Circuit container and executor backed by pure JAX kernels.

    ``Script`` takes a callable *f* representing a quantum circuit.
    Within *f*, :class:`~jaqsi.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:`jaqsi.simulation` and the memory-estimation/chunking helpers
    in :mod:`jaqsi.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:`~jaqsi.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:`~jaqsi.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:`~jaqsi.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:`~jaqsi.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,
        initial_state: Optional[jnp.ndarray] = None,
        fingerprint: Optional[Hashable] = 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.
            initial_state: Optional statevector to start the simulation from
                instead of |00…0⟩.  Without *in_axes* it must be a 1D state of
                shape ``(2**n,)``.  With *in_axes* it may be 1D (broadcast across
                the batch) or 2D of shape ``(B, 2**n)`` (one state per sample).
            fingerprint: Hashable summary of any circuit-function state that is
                read while recording the tape but does not appear in *args* or
                *kwargs*.  It takes part in the batched plan cache key, so a
                caller whose circuit structure depends on mutable external state
                (e.g. attributes of a model object wrapping this script) does not
                silently reuse a plan compiled for the previous structure.

        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)
        if initial_state is not None:
            nd = jnp.ndim(initial_state)
            if in_axes is None and nd != 1:
                raise ValueError(
                    "initial_state must be a 1D statevector when in_axes is "
                    f"None; got ndim={nd}."
                )
            if in_axes is not None and nd not in (1, 2):
                raise ValueError(
                    "Batched initial_state must be 1D (broadcast) or 2D "
                    f"(one state per sample); got ndim={nd}."
                )

        # 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,
                initial_state=initial_state,
                fingerprint=fingerprint,
            )
        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,
                initial_state=initial_state,
            )

    @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,
        has_initial_state: bool = False,
    ) -> _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).

        When *has_initial_state* is ``True`` the last entry of *args* is the
        (vmapped) initial statevector rather than a circuit argument; it is
        stripped before recording the tape and forwarded to
        :func:`~jaqsi.simulation.simulate_and_measure`.
        """
        scalar_args = tuple(
            self._slice_first(a, ax) if ax is not None else a
            for a, ax in zip(args, in_axes)
        )
        # The trailing scalar is the initial state, not a circuit argument.
        record_args = scalar_args[:-1] if has_initial_state else scalar_args
        n_qubits, use_density, n_ops = self._record_metadata(
            record_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):
            if has_initial_state:
                *circuit_args, init_state = single_args
            else:
                circuit_args, init_state = single_args, None
            single_tape = self.record(*circuit_args, **kwargs)
            return simulation.simulate_and_measure(
                single_tape, n_qubits, type, obs, use_density, initial_state=init_state
            )

        # 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:`~jaqsi.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,
        initial_state: Optional[jnp.ndarray] = None,
        fingerprint: Optional[Hashable] = 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.
            initial_state: Optional initial statevector.  A 1D state is
                broadcast across the batch; a 2D ``(B, 2**n)`` state is vmapped
                over axis 0 (one state per sample).  It is appended as an extra
                vmapped argument alongside the circuit arguments.
            fingerprint: Hashable summary of external circuit-function state
                (see :meth:`execute`); part of the plan cache key.

        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."
            )

        # Append the initial state as an extra vmapped argument so it threads
        # through the same plan/cache machinery as the circuit arguments: a 1D
        # state is broadcast (axis None), a 2D state is batched over axis 0.
        has_init = initial_state is not None
        init_axis = (0 if jnp.ndim(initial_state) == 2 else None) if has_init else None
        eff_args = args + (initial_state,) if has_init else args
        eff_in_axes = in_axes + (init_axis,) if has_init else in_axes

        batch_size = self._batch_size(eff_args, eff_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(eff_args)

        # ``a.__class__`` (not ``type(a)``: ``type`` is shadowed by the
        # measurement-type parameter) keys non-array statics by their class.
        arg_shapes = tuple(
            (a.shape, a.dtype) if hasattr(a, "shape") else a.__class__ for a in eff_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

        # Non-array kwargs (e.g. ``noise_params``, ``pulse``) change the
        # recorded circuit and are captured in the traced closure, so they have
        # to take part in both cache keys.
        cache_kwargs = make_hashable(
            {k: v for k, v in kwargs.items() if not isinstance(v, jnp.ndarray)}
        )

        # --- Shot mode: compute exact probabilities, then sample. ---
        if shots is not None and type in ("probs", "expval"):
            shot_cache_key = (
                type,
                "shots",
                shots,
                eff_in_axes,
                arg_shapes,
                cache_kwargs,
                gate_error,
                has_init,
                fingerprint,
            )
            shot_in_axes = eff_in_axes + (0,)  # shot key batched over axis 0
            shot_args = eff_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 initial state (when present)
                # and the shot key are the extra vmapped arguments.
                def _single_execute_shots(*single_args_and_key):
                    if has_init:
                        *single_args, init_state, shot_key = single_args_and_key
                    else:
                        *single_args, shot_key = single_args_and_key
                        init_state = None
                    single_tape = self.record(*single_args, **kwargs)
                    exact_result = simulation.simulate_and_measure(
                        single_tape,
                        n_qubits,
                        "probs",
                        obs,
                        use_density,
                        initial_state=init_state,
                    )
                    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. ---
        # ``has_init`` is part of the key: the plan strips the trailing argument
        # before recording the tape, so a plan built with an initial state must
        # not be reused for a same-shaped trailing circuit argument (and back).
        cache_key = (
            type,
            eff_in_axes,
            arg_shapes,
            cache_kwargs,
            gate_error,
            has_init,
            fingerprint,
        )

        # 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, eff_args, kwargs, eff_in_axes, has_initial_state=has_init
            )
            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,
            eff_args,
            eff_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 jaqsi.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 jaqsi/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 jaqsi/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 jaqsi.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, initial_state=None, fingerprint=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
initial_state Optional[ndarray]

Optional statevector to start the simulation from instead of |00…0⟩. Without in_axes it must be a 1D state of shape (2**n,). With in_axes it may be 1D (broadcast across the batch) or 2D of shape (B, 2**n) (one state per sample).

None
fingerprint Optional[Hashable]

Hashable summary of any circuit-function state that is read while recording the tape but does not appear in args or kwargs. It takes part in the batched plan cache key, so a caller whose circuit structure depends on mutable external state (e.g. attributes of a model object wrapping this script) does not silently reuse a plan compiled for the previous structure.

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 jaqsi/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,
    initial_state: Optional[jnp.ndarray] = None,
    fingerprint: Optional[Hashable] = 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.
        initial_state: Optional statevector to start the simulation from
            instead of |00…0⟩.  Without *in_axes* it must be a 1D state of
            shape ``(2**n,)``.  With *in_axes* it may be 1D (broadcast across
            the batch) or 2D of shape ``(B, 2**n)`` (one state per sample).
        fingerprint: Hashable summary of any circuit-function state that is
            read while recording the tape but does not appear in *args* or
            *kwargs*.  It takes part in the batched plan cache key, so a
            caller whose circuit structure depends on mutable external state
            (e.g. attributes of a model object wrapping this script) does not
            silently reuse a plan compiled for the previous structure.

    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)
    if initial_state is not None:
        nd = jnp.ndim(initial_state)
        if in_axes is None and nd != 1:
            raise ValueError(
                "initial_state must be a 1D statevector when in_axes is "
                f"None; got ndim={nd}."
            )
        if in_axes is not None and nd not in (1, 2):
            raise ValueError(
                "Batched initial_state must be 1D (broadcast) or 2D "
                f"(one state per sample); got ndim={nd}."
            )

    # 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,
            initial_state=initial_state,
            fingerprint=fingerprint,
        )
    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,
            initial_state=initial_state,
        )

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:~jaqsi.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:~jaqsi.drawing.PulseEvent.

Source code in jaqsi/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:`~jaqsi.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:`~jaqsi.drawing.PulseEvent`.
    """
    with pulse_recording() as events:
        with recording():
            self.f(*args, **kwargs)
    return events

record(*args, **kwargs) #

Run the circuit function and collect the recorded operations.

Uses :func:~jaqsi.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.

Parameters:

Name Type Description Default
*args

Positional arguments forwarded to the circuit function.

()
**kwargs

Keyword arguments forwarded to the circuit function.

{}

Returns:

Type Description
List[Operation]

List of :class:~jaqsi.operations.Operation instances in

List[Operation]

the order they were instantiated.

Source code in jaqsi/script.py
def record(self, *args, **kwargs) -> List[Operation]:
    """Run the circuit function and collect the recorded operations.

    Uses :func:`~jaqsi.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:`~jaqsi.operations.Operation` instances in
        the order they were instantiated.
    """
    with recording() as tape:
        self.f(*args, **kwargs)
    return tape

Drawing#

from jaqsi.drawing import TikzFigure

Wrapper around a quantikz LaTeX string with export helpers.

Source code in jaqsi/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 jaqsi/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 jaqsi/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 jaqsi.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 jaqsi/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 jaqsi.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 jaqsi/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:~jaqsi.drawing.PulseEvent instances.

Source code in jaqsi/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:`~jaqsi.drawing.PulseEvent` instances.
    """
    stack = _pulse_tape_stack()
    tape: list = []
    stack.append(tape)
    try:
        yield tape
    finally:
        stack.pop()