Source code for quchip.engine.problem

"""Package engine physics and solve inputs into frozen solve requests.

Responsibilities
----------------
* Resolve the chip frame.
* Flatten ``e_ops`` into solver-ready bands with :func:`decompose_eops`.
* Build an :class:`EngineResult` for each variant and pack it into a single
  :class:`SolveProblem`, or merge homogeneous variants into a
  :class:`SolveBatch` (``N`` identical skeletons with per-element
  :class:`ScalarModulation` signals).

Collapse operators enter the standard Lindblad master equation
``dρ/dt = −i[H, ρ] + Σₖ D[Lₖ]ρ``. Rates are stored in 1/ns.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any

import numpy as np

from quchip.approximations import Approximation
from quchip.backend import BatchSolveError
from quchip.engine.states import (
    CompactState,
    compatible_values,
    label_state,
    materialize_compact,
    materialize_state_spec,
)
from quchip.engine.ir import (
    ControlOp,
    EngineResult,
    ScalarModulation,
    SolveBatch,
    SolveProblem,
    StateStorage,
    _aggregate_batch_metadata,
)
from quchip.engine.assembly import build_engine_result
from quchip.engine.frames import resolve_for_operations
from quchip.engine.observables import decompose_eops
from quchip.engine.sampling import AutomaticTimeGrid, sample_problems
from quchip.utils.jax_utils import contains_tracer

if TYPE_CHECKING:
    from quchip.chip.chip import Chip


[docs] @dataclass(frozen=True) class SolveProblemContext: """Shared solve metadata reused across a homogeneous problem batch. Built once by :func:`prepare_solve_problem_context`, so sweep points can skip redundant observable normalization. """ chip: Chip tlist: Any e_ops: Any resolved_frame: Any approximation: Approximation _base_result: EngineResult | None solver: str | None options: dict[str, Any] run_args: dict[str, Any] states: StateStorage | None = None dissipation: bool = True automatic_sampling: bool = False
def _validate_tlist(tlist: Any) -> None: """Validate a solve-time grid; shape checks run regardless of tracing. ``tlist`` must be one-dimensional and hold at least two points -- both static array facts available on a JAX tracer's abstract shape without forcing concretization, so they raise even under ``jax.jit``. Finiteness and strict monotonicity are value-dependent and validated only when ``tlist`` is concrete. """ if tlist.ndim != 1: raise ValueError(f"tlist must be one-dimensional; got shape {tlist.shape}.") if tlist.shape[0] < 2: raise ValueError(f"tlist must have at least two points; got {tlist.shape[0]}.") if contains_tracer(tlist): return arr = np.asarray(tlist) if not np.all(np.isfinite(arr)): raise ValueError("tlist must be finite everywhere.") if not np.all(np.diff(arr) > 0): raise ValueError("tlist must be strictly increasing.")
[docs] def prepare_solve_problem_context( chip: Chip, tlist: Any, *, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, drive_ops: list[ControlOp] | None = None, approximation: Approximation | None = None, frame: Any = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, initial_states: list[Any] | None = None, _product_space: bool = False, ) -> SolveProblemContext: """Resolve the frame and keep authored observables and state specifications. Observables and states are materialized only after assembly resolves every local solver basis. The default ground state stays lazy, so callers that supply an explicit state do not pay for unused state construction. ``frame`` overrides the chip's declared frame. For ``"auto"``, the solve window ``tlist[-1] - tlist[0]`` gives the duration used for weights. ``tlist`` defines the actual interval. Scheduled signals keep their absolute times, including operations partly or fully outside this interval. """ if not isinstance(dissipation, bool): raise TypeError("dissipation must be a boolean calculation choice.") backend = chip.backend automatic = isinstance(tlist, AutomaticTimeGrid) tlist_arr = backend.array_module.asarray(tlist.bounds if automatic else tlist, dtype=float) _validate_tlist(tlist_arr) if e_ops is not None and not isinstance(e_ops, dict): raise TypeError("e_ops must be dict or None") from math import prod from quchip.engine.assembly import _resolve_system, _prepare_engine_assembly from quchip.engine.frames import plan_for_operations from quchip.engine.pulse_space import plan_pulse_space from quchip.engine.excitation import sector_size from quchip.approximations import require_approximation from quchip.cutoff import active_cutoff strategy = chip.approximation if approximation is None else require_approximation(approximation) resolution = _resolve_system(chip, backend) policy = active_cutoff(chip) deterministic = solver in (None, "sesolve", "mesolve") if policy is not None and not deterministic: raise ValueError( f"Stochastic trajectory solvers do not support an excitation cutoff, and this chip keeps only " f"N <= {policy.max_total}. Use sesolve or mesolve, or chip.with_space(None) for the full product space." ) space, reason, boundary = (None, "Trajectory solvers use the product space.", None) if _product_space and policy is None: reason = "Batch points prove different exact spaces." elif deterministic: # Under a cutoff, batch points that prove different spaces all keep every label with N <= K. space, reason, boundary = plan_pulse_space(chip, drive_ops or [], initial_states or [None], resolution, strategy, dissipation, whole=_product_space) if space is None: base_result = resolve_for_operations( chip, drive_ops or [], frame=frame, approximation=strategy, solve_window=(tlist_arr[0], tlist_arr[-1]), ) else: spec = plan_for_operations(chip, chip.frame if frame is None else frame, drive_ops or [], approximation=strategy, solve_window=(tlist_arr[0], tlist_arr[-1]), _resolution=resolution) resolution, resolved_frame = _prepare_engine_assembly(chip, spec, strategy, resolution=resolution) base_result = build_engine_result(chip, [], resolved_frame=resolved_frame, approximation=strategy, _local_resolution=replace(resolution, space=space)) base_result = replace(base_result, metadata={**base_result.metadata, "excitation_reduction": { "exact": space is not None, "reason": reason, "full_dimension": prod(resolution.dims), "dimension": prod(base_result.dims), "totals": None if space is None else space.totals, "cutoff": None if policy is None else policy.max_total, "cutoff_dimension": None if policy is None else sum( sector_size(resolution.dims, total) for total in range(policy.max_total + 1)), "boundary": boundary, }}) return SolveProblemContext( chip=chip, tlist=tlist if isinstance(tlist, tuple) and solver in ("jssesolve", "dssesolve") else tlist_arr, e_ops=e_ops, resolved_frame=base_result.resolved_frame, approximation=base_result.approximation, _base_result=base_result, solver=solver, options={} if options is None else options, run_args={} if run_args is None else run_args, automatic_sampling=automatic, states=states, dissipation=dissipation, )
def _prepare_context_eops( context: SolveProblemContext, engine_result: EngineResult, ) -> tuple[Any, Any]: """Project raw observable specs once the engine basis maps are available.""" if context.e_ops is None: return None, None return decompose_eops( context.e_ops, context.chip, context.chip.backend, engine_result.bases, engine_result=engine_result, ) def _validate_batch_skeleton(engine_results: list[EngineResult]) -> None: """Require equivalent Hamiltonian skeletons without rebuilding solve inputs.""" ref = engine_results[0] static_term_ids = ref._static_term_ids n_dyn = len(ref.dynamic_terms) _prefix = "build_solve_batch_from_results: " # --- Skeleton checks: static terms, dim shape, dynamic term count --- for idx, result in enumerate(engine_results): if result.approximation != ref.approximation: raise ValueError( _prefix + "all engine results must use the same approximation; " f"element {idx} uses {type(result.approximation).__name__}, " f"expected {type(ref.approximation).__name__}." ) if result._static_term_ids != static_term_ids: raise ValueError( _prefix + "all engine results must share identical static_terms (by identity); " f"element {idx} differs." ) if len(result.dynamic_terms) != n_dyn: raise ValueError( _prefix + "all engine results must have the same number of dynamic terms; " f"element {idx} has {len(result.dynamic_terms)}, expected {n_dyn}." ) if tuple(result.dims) != tuple(ref.dims): raise ValueError( _prefix + "all engine results must share identical dims; " f"element {idx} has {tuple(result.dims)}, expected {tuple(ref.dims)}." ) # --- Per-slot compatibility + signal collection --- # Per element, verify the dynamic term matches the reference in # (operator payload, origin, tag) and is a ScalarModulation; collect # its signal. Crosstalk rebuilds operators every instantiation, so # equality is by canonical fingerprint, not by object identity. for slot in range(n_dyn): ref_term = ref.dynamic_terms[slot] shared_operator = ref_term.operator canonical_key = shared_operator.fingerprint() for idx, result in enumerate(engine_results): term = result.dynamic_terms[slot] where = f"slot {slot}, element {idx}" if not isinstance(term.time_dependence, ScalarModulation): raise ValueError( _prefix + f"only ScalarModulation time dependencies are supported ({where})." ) if term.origin != ref_term.origin: raise ValueError( _prefix + f"dynamic term origin differs at {where} " f"({term.origin!r} vs {ref_term.origin!r})." ) if term.tag != ref_term.tag: raise ValueError( _prefix + f"dynamic term tag differs at {where} " f"({term.tag!r} vs {ref_term.tag!r})." ) if term.operator is not shared_operator and term.operator.fingerprint() != canonical_key: raise ValueError( _prefix + f"dynamic operator {where} differs from the slot reference; " "batched IR requires equivalent operator payloads across the batch." )
[docs] def build_solve_batch_from_results( context: SolveProblemContext, engine_results: list[EngineResult], *, initial_states: list[Any] | None = None, ) -> SolveBatch: """Package homogeneous :class:`EngineResult`s as one :class:`SolveBatch`. All results must share the same static term objects and the same number of dynamic terms. Each slot's operator payloads must match, by identity or by canonical fingerprint. ``initial_states=None`` gives every point one shared default start. Individual ``None`` entries reuse that start. The batch shares one static Hamiltonian, frame, and start time. """ # Crosstalk rebuilds equal-by-value operators on every instantiation. if not engine_results: raise ValueError("build_solve_batch_from_results requires at least one engine result") _validate_batch_skeleton(engine_results) ref = engine_results[0] batch_size = len(engine_results) if initial_states is None: initial_states = [None] * batch_size elif len(initial_states) != batch_size: raise ValueError( f"initial_states length {len(initial_states)} does not match batch_size {batch_size}" ) _require_product_state(context, ref) # Materialize the shared default only when at least one point omits its state. ground = ( _initial_state(context.chip, None, ref, context.tlist[0]) if any(state_spec is None for state_spec in initial_states) else None ) states = tuple( ground if state_spec is None else _initial_state(context.chip, state_spec, result, context.tlist[0]) for state_spec, result in zip(initial_states, engine_results) ) shared_metadata = _aggregate_batch_metadata(engine_results) e_ops, e_ops_meta = _prepare_context_eops(context, ref) problems: list[SolveProblem] = [] for state, result in zip(states, engine_results): problems.append( SolveProblem( chip=context.chip, engine_result=replace(result, metadata=shared_metadata, dissipation=context.dissipation), initial_state=state, tlist=context.tlist, e_ops=e_ops, e_ops_meta=e_ops_meta, resolved_frame=context.resolved_frame, solver=context.solver, options=context.options, run_args=context.run_args, states=context.states, ) ) if context.automatic_sampling: problems = sample_problems(problems) return SolveBatch(chip=context.chip, problems=tuple(problems))
def _initial_state(chip, spec, result, start): if spec is None or isinstance(spec, (Mapping, str)): return label_state(chip, spec, result, start) if result.excitation_space is not None: return materialize_compact(chip, spec, result.excitation_space) if isinstance(spec, CompactState): backend = chip.backend values = compatible_values(spec, tuple(d.label for d in chip.devices), result.dims, result.bases, backend) native = backend.from_array(values, dims=[[spec.space.size], [1] if spec.values.shape[-1] == 1 else [spec.space.size]]) spec = spec.space.lift(native, backend) return materialize_state_spec(chip, spec, result.bases) def _require_product_state(context, result): """Raise before a dissipative product-space solve evolves a density matrix that cannot fit.""" dissipative = context.dissipation and bool(result.slh.collapse_terms) if result.excitation_space is None and dissipative and context.solver in (None, "mesolve"): from math import prod from quchip.utils.memory import dense_bytes, require_memory dimension = prod(result.dims) require_memory(dense_bytes(dimension, dimension), task=f"The product-space density matrix at dimension {dimension}", remedy="Use a model and initial state with a smaller proven invariant space.")
[docs] def build_solve_problem( chip: Chip, drive_ops: list[ControlOp], tlist: Any, *, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, initial_state: Any | None = None, approximation: Approximation | None = None, frame: Any = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, _initial_states: list[Any] | None = None, _product_space: bool = False, ) -> SolveProblem: """Resolve, assemble, and package a frozen :class:`SolveProblem`. Equivalent to :func:`prepare_solve_problem_context` followed by :func:`build_engine_result`. For many variants that share one chip configuration, use that two-step form with :func:`build_solve_batch_from_results`. ``frame`` overrides the chip's declared frame and follows the same operation-aware ``"auto"`` resolution as :func:`prepare_solve_problem_context`. """ context = prepare_solve_problem_context( chip, tlist, solver=solver, options=options, run_args=run_args, e_ops=e_ops, drive_ops=drive_ops, approximation=approximation, frame=frame, states=states, dissipation=dissipation, initial_states=[initial_state] if _initial_states is None else _initial_states, _product_space=_product_space, ) return _problem_from_context(context, drive_ops, initial_state)
def _problem_from_context(context, drive_ops, initial_state): """Instantiate one solve request on a prepared context.""" engine_result = build_engine_result( context.chip, drive_ops, resolved_frame=context.resolved_frame, approximation=context.approximation, _base_result=context._base_result, ) engine_result = replace(engine_result, dissipation=context.dissipation) _require_product_state(context, engine_result) e_ops_solver, e_ops_meta = _prepare_context_eops(context, engine_result) problem = SolveProblem( chip=context.chip, engine_result=engine_result, initial_state=_initial_state(context.chip, initial_state, engine_result, context.tlist[0]), tlist=context.tlist, e_ops=e_ops_solver, e_ops_meta=e_ops_meta, resolved_frame=context.resolved_frame, solver=context.solver, options=context.options, run_args=context.run_args, states=context.states, ) return sample_problems([problem])[0] if context.automatic_sampling else problem
[docs] def solve_problem_list(problems: list[SolveProblem], *, progress: bool = True, parameters: tuple[dict[str, Any], ...] | None = None, max_workers: int | None = None) -> Any: """Dispatch each captured backend's requests and restore the original point order.""" from quchip.results.results import SimulationBatchResult problems = assign_point_noise(problems) groups: dict[int, list[tuple[int, SolveProblem]]] = {} for index, problem in enumerate(problems): groups.setdefault(id(problem.backend), []).append((index, problem)) ordered: list[Any] = [None] * len(problems) for entries in groups.values(): try: results = _solve_backend_problems([problem for _, problem in entries], entries[0][1].backend, progress=progress, max_workers=max_workers, failure_context=tuple((index, parameters[index] if parameters is not None else {}) for index, _ in entries)) except BatchSolveError as exc: raise BatchSolveError(entries[exc.index][0], exc.detail, exc.parameters) from exc for (index, _), result in zip(entries, results, strict=True): ordered[index] = result return SimulationBatchResult(ordered)
def _solve_backend_problems( problems: list[SolveProblem], backend: Any, *, progress: bool = True, failure_context: tuple[tuple[int, dict[str, Any]], ...] = (), max_workers: int | None = None, ) -> Any: """Group problems by shared operator skeleton and dispatch as :class:`SolveBatch`es. Problems that share an operator skeleton are merged into one batched solve. A backend may dispatch a large heterogeneous list independently; otherwise each structural group follows the normal batch path, with incompatible results falling back to per-problem ``backend.solve_problem`` calls. Grouping first checks shared Hamiltonian/metadata identity and compares captured observable values, grids and options. Canonical operator fingerprints then establish compatibility within each group. Returns a :class:`~quchip.results.results.SimulationBatchResult`. """ from quchip.results.results import ( SimulationBatchResult, capture_label_targets, wrap_solver_result, wrap_solver_results_from_batch, ) _tlist_cache: dict[int, tuple] = {} def _options_key(opts: dict) -> tuple: items = [] for key in sorted(opts.keys(), key=str): val = opts[key] try: hash(val) items.append((str(key), val)) except TypeError: items.append((str(key), repr(val))) return tuple(items) def _tlist_key(tlist: Any) -> tuple: if tlist is None: return ("none",) obj_id = id(tlist) if contains_tracer(tlist): return ("traced_tlist", obj_id) cached = _tlist_cache.get(obj_id) if cached is not None: return cached arr = np.asarray(tlist) key = ("tlist", arr.shape, str(arr.dtype), arr.tobytes()) _tlist_cache[obj_id] = key return key def _op_list_key(ops: Any) -> tuple: if ops is None: return ("none",) from quchip.utils.values import value_fingerprint try: return ("ops", tuple(value_fingerprint(backend.to_array(op)) for op in ops)) except ValueError: return ("opaque_ops", object()) def _skeleton_prefilter_key(problem: SolveProblem) -> tuple: desc = problem.engine_result solver_name = problem.solver_name(problem.backend) return ( solver_name, desc._static_term_ids, tuple(id(term.operator) for term in desc.dynamic_terms), tuple(term.origin for term in desc.dynamic_terms), tuple(term.tag for term in desc.dynamic_terms), _tlist_key(problem.tlist), _op_list_key(problem.e_ops), tuple(id(term.operator) for term in desc.collapse_terms), _options_key(problem.options), _options_key({key: value for key, value in problem.run_args.items() if key not in ("keys", "seeds")}), problem.states, _options_key(problem.monitoring or {}), id(problem.resolved_frame), ) groups: dict[tuple, list[tuple[int, SolveProblem]]] = {} for idx, problem in enumerate(problems): groups.setdefault(_skeleton_prefilter_key(problem), []).append((idx, problem)) if len(groups) > 1 and not any(problem.stochastic for problem in problems): parallel_results = backend.parallel_solve_problems(problems, progress=progress, max_workers=max_workers) if parallel_results is not None: if len(parallel_results) != len(problems): raise RuntimeError( "parallel_solve_problems returned " f"{len(parallel_results)} results for {len(problems)} problems." ) captured: dict[int, list[tuple[Any, Any, Any]]] = {} wrapped = [ wrap_solver_result(result, problem, backend, label_targets=capture_label_targets(problem, captured)) for problem, result in zip(problems, parallel_results) ] return SimulationBatchResult(wrapped) ordered_results: list[Any] = [None] * len(problems) for group in groups.values(): indices = [i for i, _ in group] group_problems = [p for _, p in group] ref = group_problems[0] try: _validate_batch_skeleton([problem.engine_result for problem in group_problems]) batch = SolveBatch(chip=ref.chip, problems=tuple(group_problems), _failure_context=tuple(failure_context[index] for index in indices) if failure_context else ()) except ValueError: for idx_original, problem in zip(indices, group_problems): try: result = backend.solve_problem(problem) except Exception as exc: raise BatchSolveError(idx_original, f"{type(exc).__name__}: {exc}") from exc ordered_results[idx_original] = wrap_solver_result(result, problem, backend) continue try: solver_results = backend.solve_batch(batch, progress=progress, max_workers=max_workers) except BatchSolveError as exc: raise BatchSolveError(indices[exc.index], exc.detail, exc.parameters) from exc for idx_original, wrapped_result in zip( indices, wrap_solver_results_from_batch(solver_results, batch, backend) ): ordered_results[idx_original] = wrapped_result missing = [idx for idx, result in enumerate(ordered_results) if result is None] if missing: raise RuntimeError( f"solve_problem_list failed to populate results for problem indices {missing}; " "backend returned incomplete results." ) return SimulationBatchResult(ordered_results)
[docs] def assign_point_noise(problems: list[SolveProblem], *, split_keys: bool = False) -> list[SolveProblem]: """Assign omitted QuTiP seeds in logical point order before grouping. Explicit native seeds/keys are kept, including intentionally shared noise. Dynamiqs keys stay a necessary native input. """ if split_keys and any("keys" in problem.run_args for problem in problems): import jax from jax import random assigned = [] for index, problem in enumerate(problems): keys = problem.run_args.get("keys") if problem.stochastic and keys is not None: typed = jax.dtypes.issubdtype(keys.dtype, jax.dtypes.prng_key) explicit = keys.ndim == (2 if typed else 3) point_keys = keys[index] if explicit else jax.vmap(lambda key: random.fold_in(key, index))(keys) problem = replace(problem, run_args={**problem.run_args, "keys": point_keys}) assigned.append(problem) problems = assigned if not any(p.solver in ("mcsolve", "ssesolve", "smesolve") and "seeds" not in p.run_args for p in problems): return problems seeds = np.random.SeedSequence().spawn(len(problems)) return [replace(problem, run_args={**problem.run_args, "seeds": seed}) if problem.solver in ("mcsolve", "ssesolve", "smesolve") and "seeds" not in problem.run_args else problem for problem, seed in zip(problems, seeds)]