Source code for qc_executor.base.executor_base

"""Base class for quantum circuit executors across different quantum frameworks."""

from __future__ import annotations

import logging
import os
import re
from abc import ABC, abstractmethod
from collections import OrderedDict
from typing import Any, List, overload

import numpy as np

from .circuit_base import QuantumCircuitBase
from .operator_base import QuantumOperatorBase


class _BoundedCache(OrderedDict):
    """An ordered dictionary that evicts the oldest entry when a size limit is reached.

    Args:
        max_size (int, optional): Maximum number of entries. Defaults to None (unlimited).
    """

    def __init__(self, max_size: int | None = None):
        super().__init__()
        if max_size is not None and (not isinstance(max_size, int) or max_size <= 0):
            raise ValueError(f"max_size must be None or a positive integer, got {max_size!r}.")
        self.max_size = max_size

    def __setitem__(self, key, value):
        if self.max_size is not None and key not in self and len(self) >= self.max_size:
            self.popitem(last=False)  # evict oldest entry
        super().__setitem__(key, value)


[docs] class ExecutorBase(ABC): """Base class for quantum circuit executors. Args: shots (int | None, optional): Number of shots for sampling. seed (int | None, optional): Random seed for reproducibility. log_file (str | None, optional): Path to the log file. log_level (str, optional): Logging level (for example ``"DEBUG"``, ``"INFO"``, ``"WARNING"``, ``"ERROR"``). caching (bool | None, optional): Whether to cache computation results in memory. cache_dir (str, optional): Directory for caching. max_cache_size (int | None, optional): Maximum number of entries kept in each in-memory cache. ``None`` means unlimited. """ _native_circuit_class = None _native_operator_class = None # ======================================================================== # Initialization & Configuration # ======================================================================== def __init__( self, backend: Any = None, shots: int | None = None, seed: int | None = None, log_file: str | None = None, log_level: str = "WARNING", caching: bool | None = None, cache_dir: str = "cache", max_cache_size: int | None = None, ): self._backend = backend self._shots = shots self._seed = seed self._log_file = log_file self._caching = caching self._cache_dir = cache_dir self._max_cache_size = max_cache_size # Result cache – shared across all public interface methods (method name # is part of the key to prevent cross-method collisions). self._result_cache = self._make_cache() if caching else None # Validate and resolve log level _valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} log_level_upper = log_level.upper() if log_level_upper not in _valid_levels: raise ValueError( f"Invalid log_level '{log_level}'. " f"Must be one of: {', '.join(sorted(_valid_levels))}." ) level = getattr(logging, log_level_upper) # Set up logger using a dotted hierarchy so handlers can be # configured at the 'executor' package level by callers. logger_name = f"{type(self).__module__}.{type(self).__qualname__}" self._logger = logging.getLogger(logger_name) self._logger.setLevel(level) if log_file is not None: log_file_abs = os.path.abspath(log_file) formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") # Avoid registering duplicate file handlers for the same path; # update level/formatter on an existing handler instead. existing_handler = next( ( h for h in self._logger.handlers if isinstance(h, logging.FileHandler) and h.baseFilename == log_file_abs ), None, ) if existing_handler is not None: existing_handler.setLevel(level) existing_handler.setFormatter(formatter) else: handler = logging.FileHandler(log_file_abs) handler.setLevel(level) handler.setFormatter(formatter) self._logger.addHandler(handler) def _make_cache(self) -> _BoundedCache: """Create a new bounded cache with the configured size limit.""" return _BoundedCache(self._max_cache_size) @property def shots(self) -> int | None: """Return the number of shots.""" return self._shots @shots.setter def shots(self, value: int | None) -> None: """Set the number of shots.""" raise NotImplementedError @property def remote(self) -> bool: """Return True if the execution access a remote backend.""" raise NotImplementedError
[docs] def get_config(self) -> dict: """Get the current executor configuration. Returns: dict: Dictionary containing the executor configuration parameters (shots, seed, log_file, log_level, caching, cache_dir, max_cache_size) Example: >>> executor = Executor.create("qiskit", shots=1024, seed=42) >>> config = executor.get_config() >>> print(config) # {'shots': 1024, 'seed': 42, ...} """ return { "shots": self._shots, "seed": self._seed, "log_file": self._log_file, "log_level": logging.getLevelName(self._logger.level), "caching": self._caching, "cache_dir": self._cache_dir, "max_cache_size": self._max_cache_size, }
[docs] def switch_backend(self, backend: Any, **overrides) -> "ExecutorBase": """Switch to a different backend while preserving configuration. Delegates to :meth:`Executor.switch_backend <qc_executor.factory.Executor.switch_backend>`. Args: backend: Name of the backend (e.g., ``"qiskit"``, ``"pennylane"``) or a backend instance for auto-detection. **overrides: Configuration parameters to override (e.g., shots=2048) Returns: ExecutorBase: New executor instance with the specified backend """ from qc_executor.factory import ( # pylint: disable=import-outside-toplevel,cyclic-import Executor, ) return Executor.switch_backend(self, backend, **overrides)
# ======================================================================== # Internal Infrastructure # ======================================================================== @staticmethod def _make_result_key(method_name: str, *args, **kwargs) -> tuple: """Build a hashable cache key for a public-interface call. Args: method_name: Name of the calling method (prevents key collisions between different methods). *args: Positional arguments passed to the method. ``**kwargs``: Keyword arguments passed to the method. Returns: A hashable tuple that uniquely identifies the call. """ def _to_hashable(v): if isinstance(v, np.ndarray): # Include dtype, shape, and strides (memory layout) to avoid collisions # between arrays that share the same raw bytes but differ structurally. return (v.dtype.str, v.shape, v.strides, v.tobytes()) if isinstance(v, (list, tuple)): return tuple(_to_hashable(i) for i in v) try: hash(v) return v except TypeError: # Fall back to object identity for unhashable types return id(v) return ( (method_name,) + tuple(_to_hashable(a) for a in args) + tuple(sorted((k, _to_hashable(v)) for k, v in kwargs.items())) ) @staticmethod def _normalize_parameter_values(**parameters) -> dict: """Normalize parameter values by converting indexed keys to vector keys. Converts indexed parameter keys like x[0], x[1] to vector form x=[...]. This allows unifying parameter passing across backends that expect vector-style keys (e.g., ParameterVector names like "x", "p"). Example: Input: x=[0.1, 0.2, 0.3], p=[1.0] Output: x=[0.1, 0.2, 0.3], p=[1.0] Input: x[0]=0.1, x[1]=0.2, x[2]=0.3, p=[1.0] Output: x=[0.1, 0.2, 0.3], p=[1.0] Mixing x=[...] with x[0]=... is not allowed. Args: **parameters: Parameter keyword arguments as passed to public methods. Returns: Dictionary with normalized parameters using vector keys. """ normalized = {} indexed_params = {} # Maps "x" -> ["x[0]", "x[1]", ...] vector_params = set() for key, value in parameters.items(): # Check for indexed key pattern: "x[i]" or "p[i]" match = re.match(r"^([a-zA-Z_]\w*)\[(\d+)\]$", key) if match: param_name = match.group(1) index = int(match.group(2)) if param_name not in indexed_params: indexed_params[param_name] = {} indexed_params[param_name][index] = value else: vector_params.add(key) normalized[key] = value conflicting_params = sorted(vector_params.intersection(indexed_params)) if conflicting_params: raise ValueError( "Cannot mix vector and indexed parameter forms for: " f"{', '.join(conflicting_params)}" ) # Convert collected indexed params to vector form for param_name, index_dict in indexed_params.items(): max_index = max(index_dict.keys()) vector_form = [index_dict.get(i) for i in range(max_index + 1)] if any(value is None for value in vector_form): raise ValueError( f"Incomplete indexed parameters for '{param_name}': " "missing indices would produce None values in the vector form." ) normalized[param_name] = vector_form return normalized # ======================================================================== # Public API – Core Quantum Operations # ========================================================================
[docs] def expectation_value( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], observable: QuantumOperatorBase | List[QuantumOperatorBase], **parameters, ) -> float | np.ndarray: """ Calculate the expectation value of the observable with respect to the circuit. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit or a list of circuits. observable (QuantumOperatorBase | List[QuantumOperatorBase]): The quantum observable or a list of observables. parameters: Additional values for the free parameters of the circuit(s) and the observable(s) given as keyword arguments. Both vector-style keys (e.g., ``x=[0.1, 0.2]``) and indexed keys (e.g., ``x[0]=0.1, x[1]=0.2``) are accepted and normalized. Returns: float | np.array: The expectation value either as a single float or as a numpy array if multiple circuits/observables are provided. """ self._logger.info("Computing expectation value") parameters = self._normalize_parameter_values(**parameters) if self._result_cache is not None: key = self._make_result_key("expectation_value", circuit, observable, **parameters) if key in self._result_cache: self._logger.debug("Result cache hit for expectation_value") return self._result_cache[key] result = self._expectation_value(circuit, observable, **parameters) self._result_cache[key] = result return result return self._expectation_value(circuit, observable, **parameters)
@abstractmethod def _expectation_value( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], observable: QuantumOperatorBase | List[QuantumOperatorBase], **parameters, ) -> float | np.ndarray: """Abstract implementation of expectation value computation.""" raise NotImplementedError
[docs] def expectation_value_derivatives( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], observable: QuantumOperatorBase | List[QuantumOperatorBase], *derivative, **parameters, ) -> float | np.ndarray | dict: """ Calculate the derivatives of the expectation value with respect to the parameters of the circuit. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit or a list of circuits. observable (QuantumOperatorBase | List[QuantumOperatorBase]): The quantum observable or a list of observables. derivative: The parameter(s) with respect to which the derivative is calculated. parameters: Additional values for the free parameters of the circuit(s) and the observable(s) given as keyword arguments. Both vector-style keys (e.g., ``x=[0.1, 0.2]``) and indexed keys (e.g., ``x[0]=0.1, x[1]=0.2``) are accepted and normalized. Returns: float | np.array | dict: The derivative of the expectation value: - single float/array if one derivative parameter is requested - dictionary mapping parameter names to gradient arrays if multiple parameters are requested """ self._logger.info("Computing expectation value derivatives") parameters = self._normalize_parameter_values(**parameters) if self._result_cache is not None: key = self._make_result_key( "expectation_value_derivatives", circuit, observable, derivative, **parameters ) if key in self._result_cache: self._logger.debug("Result cache hit for expectation_value_derivatives") return self._result_cache[key] result = self._expectation_value_derivatives( circuit, observable, *derivative, **parameters ) self._result_cache[key] = result return result return self._expectation_value_derivatives(circuit, observable, *derivative, **parameters)
@abstractmethod def _expectation_value_derivatives( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], observable: QuantumOperatorBase | List[QuantumOperatorBase], *derivative, **parameters, ) -> float | np.ndarray | dict: """Abstract implementation of expectation value derivatives computation.""" raise NotImplementedError
[docs] def sample( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameters ) -> dict | List[dict]: """ Computes samples of the quantumstate of the given circuit. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit or a list of circuits. parameters: Additional values for the free parameters of the circuit(s) given as keyword arguments. Both vector-style keys (e.g., ``x=[0.1, 0.2]``) and indexed keys (e.g., ``x[0]=0.1, x[1]=0.2``) are accepted and normalized. Returns: dict | List[dict]: The sampled results either as a single dictionary or a list of dictionaries if multiple circuits are provided. """ self._logger.info("Sampling circuit (shots=%s)", self._shots) parameters = self._normalize_parameter_values(**parameters) if self._result_cache is not None: # Include shots in the key so that changing shots invalidates cached samples. key = self._make_result_key("sample", circuit, self._shots, **parameters) if key in self._result_cache: self._logger.debug("Result cache hit for sample") return self._result_cache[key] result = self._sample(circuit, **parameters) self._result_cache[key] = result return result return self._sample(circuit, **parameters)
@abstractmethod def _sample( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameters ) -> dict | List[dict]: """Abstract implementation of circuit sampling.""" raise NotImplementedError
[docs] def statevector( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameters ) -> np.ndarray: """ Computes the statevector of the quantum circuit. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit or a list of circuits. parameters: Additional values for the free parameters of the circuit(s) given as keyword arguments. Both vector-style keys (e.g., ``x=[0.1, 0.2]``) and indexed keys (e.g., ``x[0]=0.1, x[1]=0.2``) are accepted and normalized. Returns: np.ndarray: The statevector of the circuit(s). """ self._logger.info("Computing statevector") parameters = self._normalize_parameter_values(**parameters) if self._result_cache is not None: key = self._make_result_key("statevector", circuit, **parameters) if key in self._result_cache: self._logger.debug("Result cache hit for statevector") return self._result_cache[key] result = self._statevector(circuit, **parameters) self._result_cache[key] = result return result return self._statevector(circuit, **parameters)
@abstractmethod def _statevector( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase], **parameters ) -> np.ndarray: """Abstract implementation of statevector computation.""" raise NotImplementedError # ======================================================================== # Public API – Circuit/Operator Handling # ========================================================================
[docs] def transpile_circuit( self, circuit: QuantumCircuitBase | List[QuantumCircuitBase] ) -> QuantumCircuitBase | List[QuantumCircuitBase]: """ Transpile the circuit for execution on this executor's backend. Subclasses may override :meth:`_transpile_circuit` to apply backend-specific optimisations (e.g. gate decomposition, qubit routing). When a list of circuits is provided, each circuit is transpiled and cached individually. Args: circuit (QuantumCircuitBase | List[QuantumCircuitBase]): The quantum circuit or a list of circuits to transpile. Returns: QuantumCircuitBase | List[QuantumCircuitBase]: The transpiled circuit(s). """ self._logger.info("Transpiling circuit") if isinstance(circuit, list): return [self._transpile_single_cached(c) for c in circuit] return self._transpile_single_cached(circuit)
def _transpile_single_cached(self, circuit: QuantumCircuitBase) -> QuantumCircuitBase: """Transpile a single circuit, consulting the result cache if enabled.""" if self._result_cache is not None: key = self._make_result_key("transpile_circuit", circuit) if key in self._result_cache: self._logger.debug("Result cache hit for transpile_circuit") return self._result_cache[key] result = self._transpile_circuit(circuit) self._result_cache[key] = result return result return self._transpile_circuit(circuit) @abstractmethod def _transpile_circuit(self, circuit: QuantumCircuitBase) -> QuantumCircuitBase: """Abstract implementation of circuit transpilation.""" raise NotImplementedError @overload def transpile_operator(self, operator: QuantumOperatorBase) -> QuantumOperatorBase: ... @overload def transpile_operator( self, operator: List[QuantumOperatorBase] ) -> List[QuantumOperatorBase]: ...
[docs] def transpile_operator( self, operator: QuantumOperatorBase | List[QuantumOperatorBase], ) -> QuantumOperatorBase | List[QuantumOperatorBase]: """ Transpile the operator for execution on this executor's backend. Subclasses may override :meth:`_transpile_operator` to apply backend-specific conversions (e.g., to wrapper types). When a list of operators is provided, each operator is transpiled and cached individually. Args: operator (QuantumOperatorBase | List[QuantumOperatorBase]): The quantum operator or a list of operators to transpile. Returns: QuantumOperatorBase | List[QuantumOperatorBase]: The transpiled operator(s). """ self._logger.info("Transpiling operator") if isinstance(operator, list): return [self._transpile_operator_cached(operator) for operator in operator] return self._transpile_operator_cached(operator)
def _transpile_operator_cached(self, operator: QuantumOperatorBase) -> QuantumOperatorBase: """Transpile a single observable, consulting the result cache if enabled.""" if self._result_cache is not None: key = self._make_result_key("transpile_operator", operator) if key in self._result_cache: self._logger.debug("Result cache hit for transpile_operator") return self._result_cache[key] result = self._transpile_operator(operator) self._result_cache[key] = result return result return self._transpile_operator(operator) @abstractmethod def _transpile_operator(self, operator: QuantumOperatorBase) -> QuantumOperatorBase: """Abstract implementation of operator transpilation. Subclasses override this to convert generic QuantumOperator to backend-native types. For backends supporting symmetry (e.g., Pauli Propagation), the symmetry_strategy parameter allows assigning a symmetry strategy to the operator. Args: operator (QuantumOperatorBase): The operator to transpile. Returns: QuantumOperatorBase: The transpiled operator in backend-native format. """ raise NotImplementedError
[docs] @classmethod @abstractmethod def get_accepted_backend_types(cls) -> List[type]: """Return a list of backend object types accepted by this executor. This is used for auto-detection when a non-string backend is passed to :meth:`Executor.create`. If the backend object is an instance of any of the returned types, this executor will be selected automatically. Returns: List[type]: List of accepted backend types (e.g., Qiskit ``Backend`` / ``BackendV2`` classes) """ raise NotImplementedError
[docs] @classmethod def get_accepted_backend_aliases(cls) -> List[str]: """Return string aliases accepted by this executor. This optional list is used by :meth:`Executor.create` when a string target is not a registered backend name. The factory resolves aliases to the owning plugin and forwards the original string via ``backend=<target>``. Returns: List[str]: String aliases accepted by the executor. """ return []