Source code for quchip.backend.protocol

"""Backend protocol and backend-agnostic solver-result containers.

quchip keeps the *physics description* separate from the *solver conversion*.
The engine (``quchip.engine``) emits structured IR (``EngineResult``,
``SolveProblem`` and ``SolveBatch``). This IR carries ordinary-GHz frequencies
and backend-free operator payloads (``CanonicalOperator``), which each backend
translates into the native form best suited to its solver. This module sets
only the *contract* (see :class:`Backend`), not the storage.

Unit convention
---------------
All frequencies that cross the backend boundary are **ordinary** (not angular)
GHz, with time in ns. The single ``2π`` conversion occurs at the engine
boundary (``assembly.py``), and backends never rescale.

Aliases
-------
``Operator`` and ``State`` are both ``typing.Any``. Concrete backends bind
them to their native type: ``qutip.Qobj`` for :class:`QuTiPBackend`,
``dynamiqs.QArray`` for :class:`DynamiqsBackend`.

References
----------
* Johansson, Nation, Nori — *QuTiP 2*, Comput. Phys. Commun. 183, 1760 (2012)
* Guilmin et al. — *dynamiqs: an open-source Python library for GPU-accelerated
  and differentiable simulation of quantum systems* (2024)
* Bradbury et al. — *JAX: composable transformations of Python+NumPy programs*
  (2018)
* Lindblad — Commun. Math. Phys. 48, 119 (1976)
* Breuer & Petruccione — *The Theory of Open Quantum Systems* (OUP, 2002)
"""

from __future__ import annotations

import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence

import numpy as np

from quchip.backend._dims import normalize_dims_from_list
from quchip.backend.containers import (
    _DEFAULT_SOLVE_OPTIONS,
    DeferredBatch,
    EagerBatch,
    EigensystemData,
    PreparedBatch,
    PreparedHamiltonian,
    PreparedStationary,
    LinearResponseSolverResult,
    CoherenceResponseSolverResult,
    SolverResult,
    SteadyStateSolverResult,
    VmappedBatch,
)

if TYPE_CHECKING:
    from quchip.engine.ir import (
        CanonicalOperator,
        EngineResult,
        SolveBatch,
        SolveProblem,
    )

Operator = Any
State = Any


def _is_unit(coefficient: Any) -> bool:
    """True for a Python ``1``, which leaves an operator unscaled in a linear combination."""
    return type(coefficient) in (int, float) and coefficient == 1


[docs] class Backend(ABC): r"""Abstract contract implemented by every quchip operator backend. A backend wraps a quantum-dynamics library (QuTiP, dynamiqs, ...) and exposes three orthogonal surfaces: * **Operator algebra**: ``destroy``/``create``/``number``/``identity``, ``tensor``, ``embed``, ``dag``, ``matmul``, and eigensystem helpers. Concrete operators support native Python arithmetic (``+ - * @``), so quchip never adds ``scale``/``add`` wrappers. * **State algebra**: ``basis``/``coherent``, ``overlap``, ``ptrace``, ``expect``, and ket ↔ density-matrix conversion. * **IR lowering & solve dispatch**: ``prepare_hamiltonian`` / ``prepare_batch`` convert engine IR into native RHS form, and ``sesolve`` / ``mesolve`` run the solver. ``solve_problem`` / ``parallel_solve_problems`` / ``solve_batch`` drive the full pipeline from engine to result. The NumPy-based defaults are correct for all backends. Subclasses override them only where they can do better (e.g. QuTiP reuses ``Qobj.eigenstates`` and dynamiqs reuses ``dq.expect``). Solver Hamiltonians use angular frequencies, while public device parameters use ordinary GHz. For operator and circuit-QED conventions, see Blais et al., `Rev. Mod. Phys. 93, 025005 (2021) <https://doi.org/10.1103/RevModPhys.93.025005>`_. """ # ------------------------------------------------------------------ # Array / scalar surface # ------------------------------------------------------------------ @property @abstractmethod def array_module(self) -> Any: """Return the array module used by backend-aware numeric code. Returns ``numpy`` for CPU-only backends and ``jax.numpy`` for JAX-traceable backends. Engine code that must stay differentiable (signal evaluation, frame assembly) routes array operations through this module, so the dynamiqs backend stays JAX-traceable end to end. """ ...
[docs] @abstractmethod def to_array(self, op: Operator) -> Any: r"""Return a dense array (native to :attr:`array_module`) for *op*. Parameters ---------- op : Operator Backend-native operator. """ ...
[docs] def overlap(self, a: State, b: State) -> complex: r"""Return the scalar inner product ⟨a|b⟩ for two kets. Parameters ---------- a, b : State Kets in the same Hilbert space, where the first is conjugated. """ a_arr = np.asarray(self.to_array(a), dtype=complex) b_arr = np.asarray(self.to_array(b), dtype=complex) return complex(np.conj(a_arr).T @ b_arr)
[docs] def norm(self, state_or_op: State | Operator) -> Any: r"""Return the Frobenius / ℓ²-norm of a state or operator. A concrete ``float`` for CPU-only backends. A JAX-traceable backend can return a native 0-d array instead, so a traced amplitude stays differentiable. A caller that needs a concrete value passes the result through :func:`~quchip.utils.jax_utils.maybe_concrete_scalar`. Parameters ---------- state_or_op : State or Operator Ket or matrix whose Euclidean/Frobenius norm is required. """ arr = np.asarray(self.to_array(state_or_op), dtype=complex) return float(np.linalg.norm(arr))
[docs] def is_native_state(self, state: Any) -> bool: r"""Return whether *state* is already represented by this backend. Parameters ---------- state : State Ket or density matrix to inspect or convert. """ return False
[docs] def trace(self, op: Operator) -> complex: r"""Return the scalar trace ``Tr(op)``. Parameters ---------- op : Operator Backend-native operator. """ arr = np.asarray(self.to_array(op), dtype=complex) return complex(np.trace(arr))
# ------------------------------------------------------------------ # Single-mode operator factories (Fock-basis defaults) # ------------------------------------------------------------------ def _single_mode(self, data: np.ndarray) -> Operator: """Wrap a dense ``n × n`` Fock-basis matrix as a native operator. Concrete backends route through :meth:`from_canonical_operator` so they can choose the optimal native layout (dense, DIA, CSR). """ from quchip.engine.ir import CanonicalOperator n = data.shape[0] return self.from_canonical_operator( CanonicalOperator.from_dense(data, dims=(n,), basis="fock", subsystem_labels=("0",)) )
[docs] def eager_operators(self) -> Any: """Return a context manager for operator construction during JAX compile-time evaluation. The default implementation leaves backend state unchanged. """ from contextlib import nullcontext return nullcontext()
[docs] def destroy(self, n: int) -> Operator: r"""Build the annihilation operator :math:`\hat a` for an *n*-level Fock space. Parameters ---------- n : int Positive Hilbert-space dimension. Number states go from 0 to n - 1. """ data = np.zeros((n, n), dtype=complex) for k in range(1, n): data[k - 1, k] = np.sqrt(k) return self._single_mode(data)
[docs] def create(self, n: int) -> Operator: r"""Build the creation operator :math:`\hat a^\dagger` for an *n*-level Fock space. Parameters ---------- n : int Positive Hilbert-space dimension. Number states go from 0 to n - 1. """ data = np.zeros((n, n), dtype=complex) for k in range(1, n): data[k, k - 1] = np.sqrt(k) return self._single_mode(data)
[docs] def number(self, n: int) -> Operator: r"""Build the number operator :math:`\hat n = \hat a^\dagger \hat a`. Parameters ---------- n : int Positive Hilbert-space dimension. Number states go from 0 to n - 1. """ return self._single_mode(np.diag(np.arange(n, dtype=complex)))
[docs] def identity(self, n: int) -> Operator: r"""Build the identity operator for an *n*-level space. Parameters ---------- n : int Positive Hilbert-space dimension. Number states go from 0 to n - 1. """ return self._single_mode(np.eye(n, dtype=complex))
[docs] def from_array(self, data: Any, dims: list[list[int]] | None = None) -> Operator: r"""Construct a native operator from a dense matrix. *dims* accepts quchip's row/col layout (``[[row_dims], [col_dims]]``) or a flat list. ``None`` means a single subsystem of size ``data.shape[0]``. Parameters ---------- data : array_like Dense matrix in the requested tensor-product basis. dims : list or None, default None Subsystem dimensions, flat or [[row_dims], [column_dims]]. None treats the row space as one subsystem. """ from quchip.engine.ir import CanonicalOperator arr = np.asarray(data, dtype=complex) dim_tuple = normalize_dims_from_list(dims, arr.shape[0]) labels = tuple(str(i) for i in range(len(dim_tuple))) return self.from_canonical_operator( CanonicalOperator.from_dense(arr, dims=dim_tuple, basis="fock", subsystem_labels=labels) )
[docs] def diag(self, values: Any, dims: list[list[int]] | None = None) -> Operator: r"""Construct a backend-native diagonal operator from main-diagonal *values*. A backend with sparse layouts (for example, dynamiqs sparse-DIA) should return its sparse representation so that element-wise composition with other diagonal operators stays sparse. The default builds a dense ``np.diag`` and then calls :meth:`from_array`. Parameters ---------- values : array_like Main-diagonal entries, flattened in input order. dims : list or None, default None Subsystem dimensions as in from_array. None uses one subsystem. """ from quchip.engine.ir import CanonicalOperator arr = np.asarray(values).reshape(-1) dim_tuple = normalize_dims_from_list(dims, arr.shape[0]) labels = tuple(str(i) for i in range(len(dim_tuple))) return self.from_canonical_operator( CanonicalOperator.from_dia( arr.astype(complex)[None, :], np.array([0], dtype=int), shape=(arr.shape[0], arr.shape[0]), dims=dim_tuple, basis="fock", subsystem_labels=labels, ) )
[docs] def compact_operator(self, rows: Any, columns: Any, values: Any, size: int) -> Operator: r"""Construct an operator on ``size`` compact excitation states from its entries. Repeated entries add. Each backend chooses the storage. The default stores a dense matrix, which keeps traced values traced, and raises :class:`MemoryError` before it allocates one that cannot fit. Parameters ---------- rows, columns : array_like of int Row and column index of each entry. values : array_like Value of each entry. size : int Number of compact states. """ from quchip.utils.jax_utils import concrete_array_module from quchip.utils.memory import dense_bytes, require_memory require_memory(dense_bytes(size, size, copies=2), task=f"A dense compact operator (dimension {size})", remedy="Reduce the model, the initial excitation support, or an excitation cutoff's max_total.") xp = concrete_array_module(values) result = xp.zeros((size, size), dtype=complex) if xp is np: np.add.at(result, (rows, columns), values) else: result = result.at[rows, columns].add(values) return self.from_array(result, dims=[[size], [size]])
[docs] def compact_operators_fit(self, size: int, count: int) -> bool: r"""Return whether ``count`` compact operators on ``size`` states fit in the available memory. The default stores each as a dense matrix, as :meth:`compact_operator` does. Parameters ---------- size : int Number of compact states. count : int Number of operators that exist at the same time. """ from quchip.utils.memory import dense_bytes, fits_memory return fits_memory(dense_bytes(size, size, copies=count))
[docs] @abstractmethod def to_canonical_operator(self, op: Operator) -> "CanonicalOperator": r"""Serialize a native operator into the backend-agnostic canonical IR. Parameters ---------- op : Operator Backend-native operator. """ ...
[docs] @abstractmethod def from_canonical_operator(self, canonical: "CanonicalOperator") -> Operator: r"""Reconstruct a native operator from the canonical IR payload. Parameters ---------- canonical : CanonicalOperator Backend-neutral matrix payload with its tensor dimensions and basis metadata. """ ...
# ------------------------------------------------------------------ # Operator algebra # ------------------------------------------------------------------
[docs] def coerce_operator(self, op: Operator) -> Operator: r"""Coerce an array-like local operator into backend-native form. Mirrors :meth:`coerce_state` for operators. Device-side accessors for physical coupling (for example, :meth:`~quchip.devices.protocols.ChargeCoupled.charge_coupling_operator`) return dense, trace-safe arrays, not backend-native operators. Composition entry points (:meth:`tensor`, :meth:`dag`) send their operands through this hook, so both forms compose freely. A backend whose native operators are already arrays overrides this method with a trace-safe passthrough. Parameters ---------- op : Operator Backend-native operator. """ return self.from_array(self.to_array(op))
[docs] def dag(self, op: Operator) -> Operator: r"""Return the Hermitian conjugate ``op†``. Parameters ---------- op : Operator Backend-native operator. """ arr = np.asarray(self.to_array(op), dtype=complex) return self.from_array(np.conj(arr).T)
[docs] def matmul(self, a: Operator, b: Operator) -> Operator: r"""Return the matrix product ``a @ b``. Parameters ---------- a, b : Operator Left and right matrix factors with compatible dimensions. """ return a @ b
[docs] def linear_combination(self, terms: Sequence[tuple[Any, Operator]]) -> Operator: r"""Return ``Σᵢ cᵢ·Aᵢ``, adding the terms in order. A backend can accumulate concrete terms in its native layout, whereas this default uses operator arithmetic. A coefficient of exactly ``1`` adds its operator unscaled. Parameters ---------- terms : sequence of (scalar, Operator) At least one coefficient and operator pair on a common space. """ total = None for coefficient, op in terms: term = op if _is_unit(coefficient) else coefficient * op total = term if total is None else total + term if total is None: raise ValueError("linear_combination needs at least one term.") return total
[docs] def eigenenergies(self, op: Operator) -> Any: r"""Return the ascending eigenvalues of a Hermitian operator. Parameters ---------- op : Operator Hermitian operator to diagonalize. """ dense = np.asarray(self.to_array(op), dtype=complex) return np.linalg.eigvalsh(dense)
[docs] def eigenstates(self, op: Operator) -> tuple[Any, Any]: r"""Return ``(eigenvalues, eigenstates)`` of a Hermitian operator, ascending. Parameters ---------- op : Operator Hermitian operator to diagonalize. """ data = self.eigensystem_data(op) return data.eigenvalues, data.eigenstates
[docs] def eigensystem_data(self, op: Operator) -> EigensystemData: r"""Return ascending eigenvalues, stacked eigenvector matrix, and lazy eigenstates. Parameters ---------- op : Operator Hermitian operator to diagonalize. """ dense = np.asarray(self.to_array(op), dtype=complex) evals, evecs = np.linalg.eigh(dense) def build_states() -> list[Any]: return [self.from_array(evecs[:, idx:idx + 1]) for idx in range(evecs.shape[1])] return EigensystemData( eigenvalues=evals, eigenvector_matrix=evecs, _states_builder=build_states, )
[docs] def expect(self, op: Operator, state: State) -> complex: r"""Return the expectation value ⟨state|op|state⟩ for a ket or a density matrix. Parameters ---------- op : Operator Observable with the same tensor dimensions as state. state : State Ket or density matrix, not normalized by the method. """ op_arr = np.asarray(self.to_array(op), dtype=complex) state_arr = np.asarray(self.to_array(state), dtype=complex) if state_arr.ndim == 2 and state_arr.shape[1] == 1: return complex(np.conj(state_arr).T @ op_arr @ state_arr) return complex(np.einsum("ij,ji->", op_arr, state_arr))
[docs] def ptrace(self, state: State, keep: int | list[int], dims: list[int]) -> State: """Reduce a composite state onto subsystem(s) *keep* with a partial trace. Parameters ---------- state Composite ket or density matrix over the subsystems in *dims*. keep Subsystem index (or indices) to keep. All other subsystems are traced out. dims Subsystem dimensions of the full composite space, in order. """ if isinstance(keep, int): keep = [keep] state_arr = np.asarray(self.to_array(state), dtype=complex) n = len(dims) if state_arr.ndim == 2 and state_arr.shape[1] == 1: state_arr = state_arr @ np.conj(state_arr).T rho = state_arr.reshape(list(dims) + list(dims)) for idx in reversed(sorted(set(range(n)) - set(keep))): rho = np.trace(rho, axis1=idx, axis2=idx + n) n -= 1 kept_dim = int(np.prod([dims[k] for k in keep])) return self.from_array(rho.reshape(kept_dim, kept_dim))
[docs] def permute_state(self, state: State, dims: Sequence[int], order: Sequence[int]) -> State: r"""Reorder the subsystems of a composite state. ``dims`` are the subsystem levels in *state*'s current tensor order. ``order`` follows the ``numpy.transpose`` convention, where ``order[i]`` is the subsystem index in the *current* order that becomes position ``i`` after the permutation, so ``order == list(range(len(dims)))`` is a no-op. Default, correct for all backends: densify, reshape to one axis per subsystem, ``transpose``, and reshape back. A ket reshapes/transposes once. A density matrix applies the same subsystem permutation to the row index group and to the column index group. Concrete backends can override this method with a native reorder (e.g. QuTiP's ``Qobj.permute``), which stays sparse-friendly and keeps the dims metadata. Every array operation goes through :attr:`array_module` (never bare ``numpy``), so a traced state stays traced end to end under the dynamiqs backend. Parameters ---------- state : State Ket or density matrix in the current tensor order. dims : sequence of int Current subsystem dimensions. order : sequence of int Permutation of subsystem indices, listing their positions in the new order. """ # `dims`/`order` are always concrete Python ints (subsystem structure, # never a physics parameter), so their product is computed with # `math.prod`, not with `array_module.prod`. If a shape value goes # through the array module, it becomes a tracer under `jit`, and the # next `reshape` fails. xp = self.array_module arr = xp.asarray(self.to_array(state), dtype=complex) dims = list(dims) is_ket = arr.ndim == 2 and arr.shape[1] == 1 if is_ket: ket_axes = arr.reshape(dims) permuted_ket_axes = xp.transpose(ket_axes, order) return self.from_array(permuted_ket_axes.reshape(-1, 1)) n_subsystems = len(dims) row_then_col_axes = arr.reshape(dims + dims) # The row axes take `order` directly; the column axes need the same # permutation, shifted by `n_subsystems` since they occupy the second # half of the reshaped axes. row_and_col_order = list(order) + [n_subsystems + i for i in order] permuted = xp.transpose(row_then_col_axes, row_and_col_order) total_dim = math.prod(dims) return self.from_array(permuted.reshape(total_dim, total_dim))
# ------------------------------------------------------------------ # Batched-over-time extraction # ------------------------------------------------------------------ # # A trajectory is ``T`` saved states. The user-facing extractors # (``population``, ``populations``, ``overlap``, # ``amplitude_array``) need one scalar/vector per save point. Doing that # with a Python loop over ``T`` single-state ``expect``/``ptrace`` calls # makes per-point dispatch the dominant cost on long ``tlist``s and # clean-``O(N)`` sweeps, rather than the matrix work itself. The methods # below collapse that loop into a single batched op over the leading # time axis. # # The native stacked form is the backend's own (a stacked ``QArray`` for # dynamiqs, a ``np.stack`` for QuTiP) so neither layout is forced on the # other. dynamiqs implementations stay ``jnp`` — storing/operating on one # stacked array is strictly more traceable than a Python list of # per-time tracers.
[docs] def stack_states(self, states: Any) -> Any: r"""Stack the saved states of a trajectory into a single ``(T, …)`` array. The default densifies each state and applies ``np.stack`` along a new leading time axis. A backend whose solver already returns a stacked native state (dynamiqs) overrides this method with a no-op pass-through. Parameters ---------- states : sequence of State Saved states in time order, with matching shapes and tensor dimensions. """ return np.stack([np.asarray(self.to_array(s), dtype=complex) for s in states])
@staticmethod def _is_ket_stack(stacked: Any) -> bool: """Return whether a stacked trajectory is kets ``(T, n, 1)`` vs DMs ``(T, n, n)``.""" return stacked.ndim == 3 and stacked.shape[2] == 1
[docs] def expect_over_time(self, op: Operator, stacked_states: Any) -> Any: r"""Return ⟨op⟩(t) for every save point, as one ``(T,)`` array in :attr:`array_module`. Accepts a ket stack ``(T, n, 1)`` (returns ⟨ψ|op|ψ⟩) or a density-matrix stack ``(T, n, n)`` (returns ``Tr(op·ρ)``). One matrix product replaces the per-point ``expect`` loop. Every intermediate stays in :attr:`array_module`, so the JAX backend keeps the trace differentiable. Parameters ---------- op : Operator Observable in the tensor-product basis of the states. stacked_states : array_like Backend-stacked kets or density matrices from stack_states. """ xp = self.array_module op_arr = xp.asarray(self.to_array(op), dtype=complex) rho = xp.asarray(self.to_array(stacked_states), dtype=complex) if self._is_ket_stack(rho): psi = rho[..., 0] return xp.einsum("ti,ti->t", xp.conj(psi), psi @ op_arr.T) return xp.einsum("tij,ji->t", rho, op_arr)
[docs] def overlap_over_time(self, target: State, stacked_states: Any) -> Any: r"""Return ⟨target|ψ(t)⟩ for every save point, as one ``(T,)`` array. For a ket stack, the result is the **phase-sensitive complex amplitude** ⟨target|ψ(t)⟩ (never ``|·|²``), so phase-dependent gradients flow through it. A density-matrix stack has no single phase, so the method returns the target-state population ⟨target|ρ(t)|target⟩. The calculation stays in :attr:`array_module` for JAX traceability. Parameters ---------- target : State Reference ket in the same Hilbert space. stacked_states : array_like Backend-stacked saved states from stack_states. """ xp = self.array_module tgt = xp.asarray(self.to_array(target), dtype=complex).reshape(-1) arr = xp.asarray(self.to_array(stacked_states), dtype=complex) if self._is_ket_stack(arr): return xp.einsum("i,ti->t", xp.conj(tgt), arr[..., 0]) return xp.einsum("i,tij,j->t", xp.conj(tgt), arr, tgt)
[docs] def populations_over_time(self, stacked_states: Any) -> Any: r"""Return full-chip diagonal populations ``(T, ∏dims)`` (real) for every save point. For a ket stack, the result is ``|ψ(t)|²`` along the level axis (the density matrix is never built). For a DM stack, it is the real diagonal. The calculation stays in :attr:`array_module` for JAX traceability. Parameters ---------- stacked_states : array_like Backend-stacked saved states. The trailing dimensions encode ket or matrix entries. """ xp = self.array_module arr = xp.asarray(self.to_array(stacked_states), dtype=complex) if self._is_ket_stack(arr): return xp.abs(arr[..., 0]) ** 2 return xp.real(xp.diagonal(arr, axis1=1, axis2=2))
[docs] def ptrace_over_time(self, stacked_states: Any, keep: int | list[int], dims: list[int]) -> Any: r"""Reduce onto subsystem(s) *keep* at every save point, and return a ``(T, k, k)`` DM stack. Accepts a ket or DM trajectory and uses no per-point Python loop. Parameters ---------- stacked_states : array_like Backend-stacked saved states from stack_states. keep : int or list of int Subsystem positions to keep. dims : list of int Full tensor-product dimensions before tracing. """ if isinstance(keep, int): keep = [keep] rho = np.asarray(stacked_states, dtype=complex) n = len(dims) if self._is_ket_stack(rho): psi = rho[..., 0] rho = np.einsum("ti,tj->tij", psi, np.conj(psi)) t_count = rho.shape[0] rho = rho.reshape([t_count] + list(dims) + list(dims)) offset = 1 # leading time axis live = n for idx in reversed(sorted(set(range(n)) - set(keep))): rho = np.trace(rho, axis1=offset + idx, axis2=offset + idx + live) live -= 1 kept_dim = int(np.prod([dims[k] for k in keep])) return self.array_module.asarray(rho.reshape(t_count, kept_dim, kept_dim))
[docs] def embed(self, op: Operator, device_index: int, dims: Sequence[int]) -> Operator: """Embed a single-device operator at *device_index* into the full tensor space. Validation is shared, and the identity factors and tensor product go through the backend's :meth:`identity` / :meth:`tensor`, so each backend keeps its native-optimal layout. Parameters ---------- op Single-device operator whose dimension matches ``dims[device_index]``. device_index Position of *op* in the tensor product. dims Subsystem dimensions of the full space, in order. """ n_devices = len(dims) if device_index < 0 or device_index >= n_devices: raise ValueError(f"device_index {device_index} out of range for {n_devices} devices") if op.shape[0] != dims[device_index]: raise ValueError( f"Operator dimension {op.shape[0]} does not match dims[{device_index}] = {dims[device_index]}" ) factors = [op if i == device_index else self.identity(d) for i, d in enumerate(dims)] return self.tensor(*factors)
[docs] @abstractmethod def embed_two_body( self, op_ab: Operator, index_a: int, index_b: int, dims: Sequence[int], ) -> Operator: r"""Embed a two-body operator on devices *index_a* ⊗ *index_b* into the full space. Handles the subsystem SWAP when ``index_a > index_b`` and the identity-padding for non-adjacent devices. Backends with sparse layouts do not materialize the dense full matrix. Parameters ---------- op_ab : Operator Two-subsystem operator, in index_a then index_b tensor order. index_a, index_b : int Distinct positions of the target subsystems in dims. dims : sequence of int Full tensor-product dimensions in chip order. """ ...
# ------------------------------------------------------------------ # State factories # ------------------------------------------------------------------
[docs] def basis(self, n: int, k: int) -> State: r"""Build the Fock basis ket :math:`|k\rangle` in an *n*-level space. Parameters ---------- n : int Hilbert-space dimension. k : int Number-state index, satisfying 0 <= k < n. """ vec = np.zeros((n, 1), dtype=complex) vec[k, 0] = 1.0 return self.from_array(vec)
[docs] def tensor_states(self, *states: State) -> State: r"""Return the tensor product of states (defaults to :meth:`tensor`). Parameters ---------- *states : State Factors in tensor-product order. """ return self.tensor(*states)
[docs] def coherent(self, n: int, alpha: complex) -> State: r"""Build the coherent state :math:`|\alpha\rangle` truncated to *n* Fock levels. Built from the analytic series :math:`|\alpha\rangle = e^{-|\alpha|^2/2} \sum_k \alpha^k/\sqrt{\Gamma(k+1)}\,|k\rangle`. Parameters ---------- n : int Fock-space truncation. alpha : complex Dimensionless coherent-state amplitude. Its squared magnitude sets the untruncated mean occupation. """ import math coeffs = np.array( [alpha**k / np.sqrt(math.factorial(k)) for k in range(n)], dtype=complex, ) coeffs *= np.exp(-0.5 * abs(alpha) ** 2) return self.from_array(coeffs.reshape(n, 1))
[docs] def state_to_dm(self, state: State) -> State: r"""Return a density matrix, or pass *state* through if *state* is already one. Parameters ---------- state : State Ket or density matrix to inspect or convert. """ if not self.is_ket(state): return state arr = np.asarray(self.to_array(state), dtype=complex).reshape(-1, 1) return self.from_array(arr @ np.conj(arr).T)
[docs] def is_ket(self, state: State) -> bool: r"""Return whether *state* is a ket (flat or column vector) rather than a density matrix. Parameters ---------- state : State Ket or density matrix to inspect or convert. """ arr = np.asarray(self.to_array(state), dtype=complex) return arr.ndim == 1 or (arr.ndim == 2 and arr.shape[1] == 1)
[docs] def as_density_matrix(self, state: State) -> State: r"""Promote a ket to its density matrix, and pass a density matrix through unchanged. Parameters ---------- state : State Ket or density matrix to inspect or convert. """ return self.state_to_dm(state) if self.is_ket(state) else state
[docs] @abstractmethod def tensor(self, *operators: Operator) -> Operator: r"""Return the tensor product of operators, and keep the subsystem dims metadata. Parameters ---------- *operators : Operator Factors in tensor-product order. """ ...
# ------------------------------------------------------------------ # Solver dispatch # ------------------------------------------------------------------
[docs] @abstractmethod def sesolve( self, H: Any, psi0: State, tlist: Any, e_ops: list[Operator] | None = None, options: dict[str, Any] | None = None, ) -> SolverResult: """Solve the Schrödinger equation :math:`i\\hbar \\partial_t |\\psi\\rangle = H |\\psi\\rangle`. Parameters ---------- H Native Hamiltonian (``Qobj``/``QobjEvo`` or dynamiqs ``TimeQArray``). psi0 Initial ket. tlist Save times in ns. e_ops Optional observables. The solver accumulates ⟨ψ|Oᵢ|ψ⟩ along the trajectory. options Backend-specific options dict. For the supported keys, see each backend's ``resolve_solver_options``. """ ...
[docs] @abstractmethod def mesolve( self, H: Any, rho0: State, tlist: Any, c_ops: list[Operator] | None = None, e_ops: list[Operator] | None = None, options: dict[str, Any] | None = None, ) -> SolverResult: r"""Solve the Lindblad master equation (Lindblad 1976; Breuer & Petruccione 2002). Integrates :math:`\dot\rho = -i[H,\rho] + \sum_k \mathcal D[L_k]\rho` with :math:`\mathcal D[L]\rho = L\rho L^\dagger - \tfrac12\{L^\dagger L, \rho\}`. Parameters ---------- H : object Native Hamiltonian, already scaled to angular frequency in rad/ns. Do not multiply by 2*pi again. rho0 : State Initial density matrix, or a ket that the concrete solver accepts. tlist : array_like, shape (nt,) Save times in ns, strictly increasing. c_ops : list of Operator or None, default None Collapse operators including square-root rates in 1/sqrt(ns). None gives no dissipators. e_ops : list of Operator or None, default None Observables in output order. None requests no expectation traces. options : dict or None, default None Native integration options. See the concrete backend's resolve_solver_options method. Returns ------- SolverResult Saved states, expectation traces and solver diagnostics. """ ...
[docs] def batched_sesolve( self, problems: list[dict[str, Any]], *, n_jobs: int = -1, progress: bool = True, ) -> list[SolverResult]: r"""Solve multiple sesolve problems, sequentially by default. Backends that can vectorize (dynamiqs ``vmap``) or parallelize (QuTiP with loky) override this method. Each problem dict carries the same kwargs as :meth:`sesolve`. Parameters ---------- problems : list of dict One keyword-argument mapping per sesolve call. n_jobs : int, default -1 Worker count, where -1 requests all CPUs. The base sequential implementation ignores it. progress : bool, default True Request a progress display if the backend supports it. Returns ------- list of SolverResult Results in input order. """ return [self.sesolve(**p) for p in problems]
[docs] def batched_mesolve( self, problems: list[dict[str, Any]], *, n_jobs: int = -1, progress: bool = True, ) -> list[SolverResult]: r"""Solve multiple mesolve problems, sequentially by default. Parameters ---------- problems : list of dict One keyword-argument mapping per mesolve call. n_jobs : int, default -1 Worker count, where -1 requests all CPUs. The base sequential implementation ignores it. progress : bool, default True Request a progress display if the backend supports it. Returns ------- list of SolverResult Results in input order. """ return [self.mesolve(**p) for p in problems]
# ------------------------------------------------------------------ # IR lowering # ------------------------------------------------------------------
[docs] def prepare_hamiltonian( self, description: "EngineResult", tlist: Any, ) -> "PreparedHamiltonian": r"""Convert a :class:`EngineResult` into a native solver RHS. The engine supplies frequencies already converted by ``2π`` (angular rad/ns), so backends must not rescale. Concrete backends override this method to select their native time-dependence representation. Parameters ---------- description : EngineResult Captured engine Hamiltonian and channels in solver units. tlist : array_like Requested save times in ns, used by native time-dependent lowering. Returns ------- PreparedHamiltonian Native right-hand side and numerical integration hints. """ raise NotImplementedError(f"{type(self).__name__} must implement prepare_hamiltonian()")
[docs] def resolve_solver_options( self, options: dict[str, Any], *, metadata: dict[str, Any], tlist: Any, ) -> dict[str, Any]: r"""Merge user options with backend-side defaults and metadata heuristics. The default is a shallow copy. Concrete backends use *metadata* (for example, ``spectral_bound_ghz``) to fill in integrator step budgets the user did not specify. Parameters ---------- options : dict User integration settings, copied before missing defaults are filled. metadata : dict Lowering hints, including ordinary-GHz spectral and carrier bounds when available. tlist : array_like Save-time grid in ns, used to estimate integration budgets. Returns ------- dict Resolved options. The input mapping is unchanged. """ return dict(options)
def _resolve_automatic_solver_options( self, options: dict[str, Any], *, user_options: dict[str, Any], engine_result: "EngineResult", solver_name: str, tlist: Any, ) -> dict[str, Any]: """Select a backend-native fast path when the assembled problem is eligible. The protocol default preserves the resolved options. Concrete backends may use the engine result's static structure and fixed Hilbert-space dimensions to select a native constant-generator method. Physics values must not be concretized here. """ _ = user_options, engine_result, solver_name, tlist return dict(options)
[docs] def coerce_state(self, state: State, dims: tuple[int, ...] | None = None) -> State: r"""Convert a foreign-native *state* into this backend's native form. The solve boundary calls this method so that a per-call ``backend=`` override (see :meth:`QuantumSequence.simulate`) accepts initial states built under another backend. For example, a QuTiP ``Qobj`` from ``chip.state(...)`` given to a dynamiqs gradient solve. Default: pass through unchanged. ``dims`` are the chip's subsystem levels, for backends whose state type carries tensor structure. Parameters ---------- state : State or array_like Ket or density matrix to convert to this backend. dims : tuple of int or None, default None Target tensor dimensions. None lets the backend infer the dimensions. """ _ = dims return state
[docs] def solve_problem(self, problem: "SolveProblem") -> SolverResult: r"""Lower and solve a :class:`SolveProblem` (the single-element entry point). Delegates to :meth:`SolveProblem.solver_name`. It uses ``sesolve`` only for a ket with no collapse operators and ``mesolve`` otherwise, unless ``problem.solver`` forces a choice. Parameters ---------- problem : SolveProblem Complete captured Hamiltonian, initial state, time grid, observables and solver settings. Returns ------- SolverResult Native solver payload for the requested calculation. """ if problem.stochastic: raise NotImplementedError(f"{type(self).__name__} does not implement native solver {problem.solver!r}.") prepared = self.prepare_hamiltonian(problem.engine_result, problem.tlist) tlist_arr, c_ops, solver, opts, e_ops_arg = self._resolve_solve_config( problem, prepared ) psi0 = self.coerce_state(problem.initial_state, dims=problem.engine_result.dims) if solver == "sesolve": return self.sesolve(prepared.rhs, psi0, tlist_arr, e_ops=e_ops_arg, options=opts) return self.mesolve(prepared.rhs, psi0, tlist_arr, c_ops=c_ops, e_ops=e_ops_arg, options=opts)
[docs] def trajectory_view( self, native: Any, problem: "SolveProblem", *, index: int | None, states: bool, deterministic: bool, ) -> tuple[Any, Any, Any]: """Read one view of a native stochastic result of this backend. Parameters ---------- native : object Native stochastic result that :meth:`solve_problem` returned. problem : SolveProblem Captured request, with its solver, state storage and native options. index : int or None Kept trajectory, or ``None`` for the native ensemble average. states : bool Whether to read the saved states. deterministic : bool Whether to read the deterministic no-click run instead. Returns ------- tuple Expectation values, state history and final state. Each entry is ``None`` when the native result does not keep it. """ raise NotImplementedError(f"{type(self).__name__} does not implement native solver {problem.solver!r}.")
[docs] def trajectory_records(self, native: Any, problem: "SolveProblem") -> tuple[Any, str]: """Read the diffusive measurement currents of a native stochastic result. Parameters ---------- native : object Native stochastic result that :meth:`solve_problem` returned. problem : SolveProblem Captured request, with its solver and native options. Returns ------- tuple Currents of shape ``(runs, channels, intervals)``, or ``(runs, channels, 2, intervals)`` for heterodyne detection, and the sampling of the signal in each interval: ``"start"``, ``"middle"``, ``"end"`` or ``"mean"``. """ raise NotImplementedError(f"{type(self).__name__} does not implement native solver {problem.solver!r}.")
[docs] def trajectory_clicks(self, native: Any, problem: "SolveProblem") -> tuple[list[Any], Any, Any, Any]: """Read the jump events of a native stochastic result. Parameters ---------- native : object Native stochastic result that :meth:`solve_problem` returned. problem : SolveProblem Captured request, with its solver and native options. Returns ------- tuple For each run, its click times and the native jump-operator index of each click; the sampling weight of each run; the weight of the no-click run that the native sampling solves apart; and whether each run filled its native click buffer. """ raise NotImplementedError(f"{type(self).__name__} does not implement native solver {problem.solver!r}.")
[docs] def trajectory_run_expect(self, native: Any, problem: "SolveProblem") -> list[Any]: """Read the expectation values of every kept run of a native stochastic result. Parameters ---------- native : object Native stochastic result that :meth:`solve_problem` returned. problem : SolveProblem Captured request, with its native expectation operators. Returns ------- list One array of shape ``(runs, times)`` for each native expectation operator. """ raise NotImplementedError(f"{type(self).__name__} does not implement native solver {problem.solver!r}.")
def _stationary_liouvillian(self, engine_result: "EngineResult") -> Any: """Lower a static engine description to the backend's native generator.""" raise NotImplementedError(f"{type(self).__name__} must implement stationary lowering")
[docs] def prepare_stationary( self, engine_result: "EngineResult", *, prepared: PreparedStationary | None = None, ) -> PreparedStationary: r"""Prepare once, or reuse the same backend and captured operating point. Parameters ---------- engine_result : EngineResult Captured static Hamiltonian and collapse channels. prepared : PreparedStationary or None, default None Matching prepared generator. ``None`` builds it. Returns ------- PreparedStationary Native stationary generator tied to this operating point. """ if prepared is None: return PreparedStationary(self, engine_result, self._stationary_liouvillian(engine_result)) if prepared.backend is not self or prepared.engine_result is not engine_result: raise ValueError("Stationary preparation belongs to a different backend or operating point.") return prepared
[docs] def stationary_bytes(self, engine_result: "EngineResult") -> int | None: """Return the estimated peak bytes of a stationary solve of a static model, or ``None``. Parameters ---------- engine_result : EngineResult Captured static Hamiltonian and collapse channels. """ return None
[docs] def steadystate( self, problem: Any, *, prepared: PreparedStationary | None = None, guess: State | None = None, ) -> SteadyStateSolverResult: r"""Solve one static Lindblad problem in the backend's native representation. Parameters ---------- problem : SteadyStateProblem Captured static model, observables, and stationary solver options. prepared : PreparedStationary or None, default None Matching prepared generator. ``None`` builds it. guess : State or None, default None Native stationary state of a related generator, such as the same model in a neighbouring frame. If this state is stationary here to round-off, a backend can return it instead of solving. The diagnostics are then evaluated on this problem's generator. Returns ------- SteadyStateSolverResult Stationary density matrix and convergence diagnostics. """ raise NotImplementedError(f"{type(self).__name__} must implement steadystate()")
[docs] def linear_response(self, problem: Any) -> LinearResponseSolverResult: r"""Solve one passive-linear input-output problem in the backend's native arrays. Parameters ---------- problem : LinearResponseProblem Captured passive-linear model, selected ports, probe frequencies, and incident amplitudes. Returns ------- LinearResponseSolverResult Scattering response, internal amplitudes and numerical diagnostics. """ raise NotImplementedError(f"{type(self).__name__} must implement linear_response()")
[docs] def coherence_response(self, problem: Any) -> CoherenceResponseSolverResult: """Solve a frequency response on adjacent excitation-sector coherences. Parameters ---------- problem : CoherenceResponseProblem Sector Hamiltonians, channels and frozen-reference probe sources. """ raise NotImplementedError(f"{type(self).__name__} must implement coherence_response()")
[docs] def sector_generator(self, sizes: Sequence[int], hamiltonian: Any, lowering: Sequence[Any], conserving: Sequence[Any]) -> Any: r"""Return the truncated Lindblad generator of a conserving model on the totals ``N <= K``. The generator solves stationary states, resolvents and regression propagations matrix-free on excitation sectors. This default solves on host NumPy and SciPy arrays for every backend, so it needs concrete values. Parameters ---------- sizes : sequence of int Dimension of each sector ``N = 0..K``. hamiltonian : scipy.sparse matrix Static Hamiltonian in rad/ns, in the lab frame and relative to the vacuum energy, with states ordered by ``N``. lowering, conserving : sequence of scipy.sparse matrix Channel couplings in ``1/sqrt(ns)`` that lower ``N`` by one or conserve it. Returns ------- SectorGenerator Generator with ``stationary()``, ``resolvent()`` and ``propagate()``. """ from quchip.backend._sector_stationary import SectorGenerator return SectorGenerator(sizes, hamiltonian, lowering, conserving)
[docs] def sector_solve_bytes(self, sizes: Sequence[int], *, smallest: bool = False) -> int: """Return the estimated peak bytes of a stationary solve on excitation sectors. Parameters ---------- sizes : sequence of int Dimension of each sector ``N = 0..K``. smallest : bool, default=False Estimate at the shortest Krylov restart, which is the least memory that a solve can run with, instead of the default restart. """ from quchip.backend._sector_stationary import _MINIMUM_RESTART, _RESTART, sector_solve_bytes return sector_solve_bytes(sizes, _MINIMUM_RESTART if smallest else _RESTART)
[docs] def sector_unique(self, sizes: Sequence[int], lowering: Sequence[Any]) -> bool: """Return whether the lowering channels prove one stationary state on the sectors ``N <= K``. The proof holds whatever the Hamiltonian, a probe and the conserving channels. Without a probe, that state is the vacuum. Parameters ---------- sizes : sequence of int Dimension of each sector ``N = 0..K``. lowering : sequence of scipy.sparse matrix Channel couplings in ``1/sqrt(ns)`` that lower ``N`` by one. """ from quchip.backend._sector_stationary import has_dark_state return not has_dark_state(sizes, lowering)
[docs] def stationary_resolvent( self, engine_result: "EngineResult", sources: tuple[tuple[str, "CanonicalOperator"], ...], observables: tuple[tuple[str, "CanonicalOperator"], ...], frequencies: Any, *, prepared: PreparedStationary | None = None, ) -> dict[tuple[str, str], Any]: r"""Evaluate trace-zero stationary resolvents in native backend arrays. For each ordinary-GHz offset ``f``, form and factor the trace-constrained shifted Liouvillian once, then solve ``(L + i 2π f) X = source`` with ``Tr(X) = 0`` for every named source column. Return ``Tr(observable X)`` keyed by ``(source, observable)``. The engine supplies canonical operators. The backend owns Liouvillian construction, factorization, and solves. Parameters ---------- engine_result : EngineResult Captured static operating point. sources : tuple of (str, CanonicalOperator) Named source columns for the constrained linear systems. observables : tuple of (str, CanonicalOperator) Named operators evaluated against each solution. frequencies : array_like Offset frequencies in ordinary GHz. prepared : PreparedStationary or None, default None Matching prepared generator. ``None`` builds it. Returns ------- dict Arrays keyed by (source_name, observable_name), indexed by frequency. """ raise NotImplementedError(f"{type(self).__name__} must implement stationary_resolvent()")
[docs] def stationary_propagate( self, engine_result: "EngineResult", initial: "CanonicalOperator", observables: tuple[tuple[str, "CanonicalOperator"], ...], times: Any, *, prepared: PreparedStationary | None = None, ) -> dict[str, Any]: r"""Propagate an operator under a static Liouvillian and evaluate observables. ``initial`` does not have to be a normalized density matrix. This supports quantum-regression queries, and the backend keeps ownership of the propagation algorithm and the numerical Liouvillian. Parameters ---------- engine_result : EngineResult Captured static operating point. initial : CanonicalOperator Initial operator. Regression queries need neither normalization nor positivity. observables : tuple of (str, CanonicalOperator) Named operators evaluated after propagation. times : array_like Propagation delays in ns. prepared : PreparedStationary or None, default None Matching prepared generator. ``None`` builds it. Returns ------- dict Observable names mapped to arrays indexed by delay. """ raise NotImplementedError(f"{type(self).__name__} must implement stationary_propagate()")
[docs] def parallel_solve_problems( self, problems: list["SolveProblem"], *, progress: bool = True, max_workers: int | None = None, ) -> list[SolverResult] | None: """Dispatch structurally heterogeneous problems in parallel. Parameters ---------- problems Problems that cannot be merged into one structural batch. progress Display solver progress if the backend supports it. max_workers Worker processes, for backends that solve in parallel. ``None`` lets the backend choose, and ``1`` solves in this process. Returns ------- list[SolverResult] | None Backend results in input order, or ``None`` to keep the engine's structural-group dispatch. """ _ = problems, progress, max_workers return None
[docs] def prepare_batch(self, batch: "SolveBatch") -> "PreparedBatch": r"""Lower each explicit batch problem into a prepared batch. The return type declares the batching strategy: :class:`~quchip.backend.containers.EagerBatch` (one RHS per element), :class:`~quchip.backend.containers.VmappedBatch` (one prebuilt native RHS), or :class:`~quchip.backend.containers.DeferredBatch` (backend-private payload for an overridden :meth:`solve_batch`). Default: lowers each element independently with :meth:`prepare_hamiltonian` into an :class:`EagerBatch`. Parameters ---------- batch : SolveBatch Batch of captured problems with compatible save grids. Returns ------- PreparedBatch Eager, native-vectorized, or deferred representation selected by this backend. """ rhs_list: list[Any] = [] shared_metadata: dict[str, Any] = {} for idx in range(batch.batch_size): prepared = self.prepare_hamiltonian(batch.element(idx).engine_result, batch.tlist) rhs_list.append(prepared.rhs) if not shared_metadata: shared_metadata = dict(prepared.metadata) return EagerBatch( rhs_list=rhs_list, batch_size=batch.batch_size, metadata=shared_metadata, tlist=batch.tlist, )
[docs] def solve_batch( self, batch: "SolveBatch", *, progress: bool = True, max_workers: int | None = None, ) -> list[SolverResult]: r"""Lower and solve a :class:`SolveBatch`. Default: prepares the batch, then dispatches each element's RHS through :meth:`batched_sesolve` / :meth:`batched_mesolve`. Native backends override this method to avoid the per-element unpack. Parameters ---------- batch : SolveBatch Captured problems and batch parameter values. progress : bool, default True Request a batch progress display. max_workers : int or None, default None Worker processes, for backends that solve in parallel. ``None`` lets the backend choose, and ``1`` solves in this process. Returns ------- list of SolverResult Results in batch order. """ if batch.batch_size == 0: return [] prepared = self.prepare_batch(batch) if isinstance(prepared, DeferredBatch): raise RuntimeError( f"{type(self).__name__}.prepare_batch produced a DeferredBatch; " "the default solve_batch cannot dispatch it. A backend that " "defers RHS construction must override solve_batch to consume " "its own deferred payload." ) dict_problems: list[dict[str, Any]] = [] solver_names: list[str] = [] for idx in range(batch.batch_size): problem = batch.element(idx) rhs = prepared.rhs if isinstance(prepared, VmappedBatch) else prepared.rhs_list[idx] tlist_arr = self.array_module.asarray(problem.tlist, dtype=float) c_ops = self._collapse_operators(problem.engine_result) solver_name = problem.solver_name(self) solver_names.append(solver_name) opts = self._resolve_problem_options( problem, metadata=problem.engine_result.metadata, tlist=tlist_arr, solver_name=solver_name, ) dict_problems.append( self._element_solver_kwargs( solver_name, rhs, self.coerce_state(problem.initial_state, dims=problem.engine_result.dims), tlist_arr, e_ops=problem.e_ops if isinstance(problem.e_ops, list) else None, c_ops=c_ops, options=dict(opts), ) ) if len(set(solver_names)) != 1: raise ValueError("Every SolveBatch point must resolve to the same solver.") solver_name = solver_names[0] runner = self.batched_mesolve if solver_name == "mesolve" else self.batched_sesolve return runner(dict_problems, n_jobs=-1 if max_workers is None else max_workers, progress=progress)
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _make_op_cache(self) -> Callable[[Any], Operator]: """Return an id-keyed memoizing wrapper over :meth:`from_canonical_operator`. A fresh cache per call: it is shared across the static and dynamic conversions of one batch preparation, never across calls (so JAX tracers cannot leak between traces). """ op_cache: dict[int, Operator] = {} def cached(canonical: Any) -> Operator: key = id(canonical) op = op_cache.get(key) if op is None: op = self.from_canonical_operator(canonical) op_cache[key] = op return op return cached @staticmethod def _sum_terms(terms: Any, to_native: Callable[[Any], Operator]) -> Operator | None: """Sum coefficient-scaled terms into one native operator (``None`` if empty).""" rhs: Operator | None = None for term in terms: scaled = term.coefficient * to_native(term.operator) rhs = scaled if rhs is None else rhs + scaled return rhs @staticmethod def _scalar_dynamic_terms(description: "EngineResult") -> Iterator[tuple[Any, Any]]: """Yield ``(operator, signal)`` for each ``ScalarModulation`` dynamic term. Centralizes the time-dependence filter both backends apply when lowering dynamic terms. ``DynamicTerm.time_dependence`` is typed as ``ScalarModulation``; the ``isinstance`` guard prevents a non-scalar modulation from contributing a malformed RHS. """ from quchip.engine.ir import ScalarModulation for term in description.dynamic_terms: td = term.time_dependence if isinstance(td, ScalarModulation): yield term.operator, td.signal def _merge_options( self, user_options: dict[str, Any], *, metadata: dict[str, Any], tlist: Any, ) -> dict[str, Any]: """Apply :data:`_DEFAULT_SOLVE_OPTIONS` then backend heuristics. The single option-merge boundary: defaults are applied here exactly once, and the backend's :meth:`resolve_solver_options` finalizes the dict (metadata-derived step budgets, portability key-stripping). """ merged = dict(_DEFAULT_SOLVE_OPTIONS) merged.update(user_options) return self.resolve_solver_options(merged, metadata=metadata, tlist=tlist) def _resolve_problem_options( self, problem: Any, *, metadata: dict[str, Any], tlist: Any, solver_name: str, ) -> dict[str, Any]: """Merge options and apply one backend-owned automatic-method decision.""" options = { **problem.options, "store_states": problem.states == "all", "store_final_state": problem.states == "final", } resolved = self._merge_options(options, metadata=metadata, tlist=tlist) return self._resolve_automatic_solver_options( resolved, user_options=problem.options, engine_result=problem.engine_result, solver_name=solver_name, tlist=tlist, ) def _resolve_solve_config( self, problem: Any, prepared: Any, ) -> tuple[Any, list[Any], str, dict[str, Any], list[Any] | None]: """Resolve one problem's backend-independent solve configuration. Coerces the save grid (via :attr:`array_module`), assembles collapse operators, selects the solver (``mesolve`` for a density matrix or when collapse operators are present, unless ``problem.solver`` forces a choice), and merges options through the single boundary. Returns ``(tlist_arr, c_ops, solver_name, opts, e_ops_arg)``; each backend contributes only its RHS-sourcing + native-solve dispatch tail. ``prepared`` supplies advisory metadata; physics and user options come from the frozen problem. """ engine_result = problem.engine_result tlist_arr = self.array_module.asarray(problem.tlist, dtype=float) c_ops = self._collapse_operators(engine_result) solver_name = problem.solver_name(self) opts = self._resolve_problem_options( problem, metadata=prepared.metadata, tlist=tlist_arr, solver_name=solver_name, ) e_ops_arg = problem.e_ops if isinstance(problem.e_ops, list) else None return tlist_arr, c_ops, solver_name, opts, e_ops_arg def _collapse_operators(self, engine_result: "EngineResult") -> list[Operator]: """Materialize canonical collapse terms for this backend.""" return [ self.array_module.sqrt(term.rate) * self.from_canonical_operator(term.operator) for term in engine_result.collapse_terms ] @staticmethod def _element_solver_kwargs( solver_name: str, rhs: Any, state: State, tlist: Any, *, e_ops: list[Operator] | None, c_ops: list[Operator], options: dict[str, Any], ) -> dict[str, Any]: """Assemble one batch element's solver kwargs (pure dict construction). Selects the state key (``rho0`` for ``mesolve``, ``psi0`` for ``sesolve``) and attaches ``c_ops`` only on the open-system path. No backend native type is introduced here — *rhs* / *state* / *options* are passed through unchanged. """ kwargs: dict[str, Any] = { "H": rhs, "tlist": tlist, "e_ops": e_ops, "options": options, } if solver_name == "mesolve": kwargs["rho0"] = state if c_ops: kwargs["c_ops"] = list(c_ops) else: kwargs["psi0"] = state return kwargs