Source code for quchip.backend.containers
"""Backend-agnostic solver-result and IR-lowering containers.
These frozen/auto dataclasses are the *payloads* exchanged across the backend
boundary. The engine emits them (or backends produce them) without committing
to any solver's native storage: a :class:`SolverResult` holds native states but
exposes a backend-free shape, a :class:`PreparedHamiltonian` / :class:`PreparedBatch`
carries whatever RHS the backend's solver accepts opaquely, and an
:class:`EigensystemData` defers per-column ket materialization so the dressing /
sweep hot path never pays for allocations it does not use.
Kept separate from :mod:`quchip.backend.protocol` (the :class:`Backend` ABC) so
the contract and its payloads can evolve independently.
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)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, TypeAlias
[docs]
@dataclass
class SolverResult:
"""Backend-agnostic container for a single time-evolution solve.
Attributes
----------
times
1D array of save times (ns) matching the solver's ``tlist``.
states
Saved states, one per save time, in the backend's native state type
(``Qobj`` / ``QArray``). ``None`` if ``store_states`` was disabled.
expect
Expectation-value traces. ``list[list[float]]`` indexed by
``e_ops``-then-time, or a ``dict`` when the engine supplied labelled
observables (keys are either strings or ``(drive_label, op)`` pairs).
final_state
Final state (``states[-1]`` when states are stored). Useful for
multi-segment protocols without retaining full trajectories.
stats
Solver-specific diagnostics (integrator step count, batch index,
etc.). Purely informational; no physics depends on it.
solver
The dispatched solver name (``"sesolve"`` or ``"mesolve"``).
"""
times: Any
states: list[Any] | None = None
expect: list[list[float]] | dict[str | tuple[str, str], Any] | None = None
final_state: Any | None = None
stats: dict[str, Any] = field(default_factory=dict)
solver: str = ""
[docs]
@dataclass
class PreparedHamiltonian:
"""Backend-native Hamiltonian produced by :meth:`Backend.prepare_hamiltonian`.
``rhs`` is whatever the backend's solver accepts directly — a ``Qobj`` /
``QobjEvo`` for QuTiP, a dynamiqs ``TimeQArray`` / sum of them for
dynamiqs. ``metadata`` passes engine-level hints (e.g.
``spectral_bound_ghz`` for integrator step heuristics) through opaquely.
"""
rhs: Any
metadata: dict[str, Any] = field(default_factory=dict)
[docs]
@dataclass
class EagerBatch:
"""Batched-solve payload with one native RHS per element.
The default :meth:`Backend.solve_batch` dispatches each element's RHS
through :meth:`Backend.batched_sesolve` / :meth:`Backend.batched_mesolve`.
"""
rhs_list: list[Any]
batch_size: int
metadata: dict[str, Any] = field(default_factory=dict)
tlist: Any | None = None
[docs]
@dataclass
class VmappedBatch:
"""Batched-solve payload with a single natively batched RHS.
``rhs`` covers every element at once — the dynamiqs path, where the
per-element signals are stacked along a leading batch axis and the
solver runs one vmapped call.
"""
rhs: Any
batch_size: int
metadata: dict[str, Any] = field(default_factory=dict)
tlist: Any | None = None
[docs]
@dataclass
class DeferredBatch:
"""Batched-solve payload whose RHS construction is deferred.
``shared`` carries backend-private state; the producing backend must
override :meth:`Backend.solve_batch` to consume it (the QuTiP path —
final ``QobjEvo`` assembly happens inside loky workers so main-process
overhead stays O(1) in batch size).
"""
shared: Any
batch_size: int
metadata: dict[str, Any] = field(default_factory=dict)
tlist: Any | None = None
#: What :meth:`Backend.prepare_batch` may return — the batching strategy is
#: explicit in the type, and :meth:`Backend.solve_batch` dispatches on it.
PreparedBatch: TypeAlias = EagerBatch | VmappedBatch | DeferredBatch
[docs]
@dataclass
class EigensystemData:
"""Hermitian eigensystem returned in a single diagonalization call.
``eigenvalues`` is ascending. ``eigenvector_matrix`` stacks the
eigenvectors as columns in the bare-product basis used for the
diagonalization. The per-column backend-native kets are exposed lazily
via the :attr:`eigenstates` property so the dressing / sweep hot path
(which reads only ``eigenvalues`` + ``eigenvector_matrix`` + labeling)
never pays for ``D`` backend-ket allocations and a second ``O(D**2)``
densification it does not use.
Backends populate either ``_states_builder`` (a callable that materializes
the ket list on demand) or prime ``_states_cache`` directly (when the
diagonalizer already produced the kets, e.g. QuTiP's ``Qobj.eigenstates``).
"""
eigenvalues: Any
eigenvector_matrix: Any
_states_builder: Callable[[], list[Any]] | None = None
_states_cache: list[Any] | None = None
@property
def eigenstates(self) -> list[Any]:
"""Return per-column backend-native eigenstate kets (built on first access).
Memoizes only when the materialized kets are tracer-free: under
``jit``/``grad``/``vmap`` the states carry tracers bound to the
current trace, so caching them would let a stale tracer escape into
a later trace. Concrete states are cached.
"""
if self._states_cache is not None:
return self._states_cache
if self._states_builder is None:
raise RuntimeError(
"EigensystemData has neither a primed states cache nor a "
"states builder; cannot materialize eigenstates."
)
states = self._states_builder()
from quchip.utils.jax_utils import contains_tracer
if not contains_tracer(states):
self._states_cache = states
return states
# Default options common to every solve — surfaces full state history so the
# user-facing ``SimulationResult`` has trajectories to plot.
_DEFAULT_SOLVE_OPTIONS: dict[str, Any] = {"store_states": True, "store_final_state": True}