diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index fc62dc57d..98a187fe6 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -175,6 +175,7 @@ def result_callback(job_id, result): """ import logging +import warnings from .qiskit_runtime_service import QiskitRuntimeService from .ibm_backend import IBMBackend @@ -203,3 +204,5 @@ def result_callback(job_id, result): """The environment variable name that is used to set the level for the IBM Quantum logger.""" QISKIT_IBM_RUNTIME_LOG_FILE = "QISKIT_IBM_RUNTIME_LOG_FILE" """The environment variable name that is used to set the file for the IBM Quantum logger.""" + +warnings.warn("You are using the experimental branch. Stability is not guaranteed.") diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index ba88cf482..50f08c023 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -22,7 +22,6 @@ from qiskit.providers.options import Options as TerraOptions from .options import Options -from .options.utils import set_default_error_levels from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend from .session import get_cm_session @@ -75,15 +74,6 @@ def __init__( self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None - if options is None: - self._options = asdict(Options()) - elif isinstance(options, Options): - self._options = asdict(copy.deepcopy(options)) - else: - options_copy = copy.deepcopy(options) - default_options = asdict(Options()) - self._options = Options._merge_options(default_options, options_copy) - if isinstance(session, Session): self._session = session self._service = self._session.service @@ -148,6 +138,21 @@ def __init__( raise ValueError( "A backend or session must be specified when not using ibm_cloud channel." ) + self._simulator_backend = ( + self._backend.configuration().simulator if self._backend else False + ) + + if options is None: + self._options = asdict(Options()) + elif isinstance(options, Options): + self._options = asdict(copy.deepcopy(options)) + else: + options_copy = copy.deepcopy(options) + default_options = asdict(Options()) + self._options = Options._merge_options_with_defaults( + default_options, options_copy, is_simulator=self._simulator_backend + ) + # self._first_run = True # self._circuits_map = {} # if self.circuits: @@ -169,20 +174,11 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo Returns: Submitted job. """ - combined = Options._merge_options(self._options, user_kwargs) - - if self._backend: - combined = set_default_error_levels( - combined, - self._backend, - Options._DEFAULT_OPTIMIZATION_LEVEL, - Options._DEFAULT_RESILIENCE_LEVEL, - ) - else: - combined["optimization_level"] = Options._DEFAULT_OPTIMIZATION_LEVEL - combined["resilience_level"] = Options._DEFAULT_RESILIENCE_LEVEL - + combined = Options._merge_options_with_defaults( + self._options, user_kwargs, self._simulator_backend + ) self._validate_options(combined) + primitive_inputs.update(Options._get_program_inputs(combined)) if self._backend and combined["transpilation"]["skip_transpilation"]: @@ -238,7 +234,9 @@ def set_options(self, **fields: Any) -> None: Args: **fields: The fields to update the options """ - self._options = Options._merge_options(self._options, fields) + self._options = Options._merge_options_with_defaults( + self._options, fields, self._simulator_backend + ) @abstractmethod def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 13a30e262..a579c6d0e 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -14,13 +14,19 @@ from __future__ import annotations import os -from typing import Optional, Dict, Sequence, Any, Union +from typing import Optional, Dict, Sequence, Any, Union, Mapping import logging +import numpy as np +from numpy.typing import ArrayLike + from qiskit.circuit import QuantumCircuit -from qiskit.opflow import PauliSumOp from qiskit.quantum_info.operators.base_operator import BaseOperator from qiskit.primitives import BaseEstimator +from qiskit.quantum_info import SparsePauliOp, Pauli +from qiskit.primitives.utils import init_observable +from qiskit.circuit import Parameter +from qiskit.primitives.base.base_primitive import _isreal # TODO import _circuit_key from terra once 0.23 is released from .runtime_job import RuntimeJob @@ -28,6 +34,7 @@ from .options import Options from .base_primitive import BasePrimitive from .utils.qctrl import validate as qctrl_validate +from .utils.deprecation import issue_deprecation_msg # pylint: disable=unused-import,cyclic-import from .session import Session @@ -35,6 +42,23 @@ logger = logging.getLogger(__name__) +BasisObservableLike = Union[str, Pauli, SparsePauliOp, Mapping[Union[str, Pauli], complex]] +"""Types that can be natively used to construct a :const:`BasisObservable`.""" + +ObservablesArrayLike = Union[ArrayLike, Sequence[BasisObservableLike], BasisObservableLike] + +ParameterMappingLike = Mapping[ + Parameter, Union[float, np.ndarray, Sequence[float], Sequence[Sequence[float]]] +] +BindingsArrayLike = Union[ + float, + np.ndarray, + ParameterMappingLike, + Sequence[Union[float, Sequence[float], np.ndarray, ParameterMappingLike]], +] +"""Parameter types that can be bound to a single circuit.""" + + class Estimator(BasePrimitive, BaseEstimator): """Class for interacting with Qiskit Runtime Estimator primitive service. @@ -85,6 +109,7 @@ class Estimator(BasePrimitive, BaseEstimator): """ _PROGRAM_ID = "estimator" + _ALLOWED_BASIS: str = "IXYZ01+-rl" def __init__( self, @@ -119,8 +144,11 @@ def __init__( def run( # pylint: disable=arguments-differ self, circuits: QuantumCircuit | Sequence[QuantumCircuit], - observables: BaseOperator | PauliSumOp | Sequence[BaseOperator | PauliSumOp], - parameter_values: Sequence[float] | Sequence[Sequence[float]] | None = None, + observables: Sequence[ObservablesArrayLike] + | ObservablesArrayLike + | Sequence[BaseOperator] + | BaseOperator, + parameter_values: BindingsArrayLike | Sequence[BindingsArrayLike] | None = None, **kwargs: Any, ) -> RuntimeJob: """Submit a request to the estimator primitive. @@ -155,7 +183,7 @@ def run( # pylint: disable=arguments-differ def _run( # pylint: disable=arguments-differ self, circuits: Sequence[QuantumCircuit], - observables: Sequence[BaseOperator | PauliSumOp], + observables: Sequence[ObservablesArrayLike], parameter_values: Sequence[Sequence[float]], **kwargs: Any, ) -> RuntimeJob: @@ -220,6 +248,84 @@ def _validate_options(self, options: dict) -> None: ) Options.validate_options(options) + @staticmethod + def _validate_observables( + observables: Sequence[ObservablesArrayLike] | ObservablesArrayLike, + ) -> Sequence[ObservablesArrayLike]: + def _check_and_init(obs: Any) -> Any: + if isinstance(obs, str): + pass + if not all(basis in Estimator._ALLOWED_BASIS for basis in obs): + raise ValueError( + f"Invalid character(s) found in observable string. " + f"Allowed basis are {Estimator._ALLOWED_BASIS}." + ) + elif isinstance(obs, Sequence): + return tuple(_check_and_init(obs_) for obs_ in obs) + elif not isinstance(obs, (Pauli, SparsePauliOp)) and isinstance(obs, BaseOperator): + issue_deprecation_msg( + msg="Only Pauli and SparsePauliOp operators can be used as observables.", + version="0.13", + remedy="", + ) + return init_observable(obs) + elif isinstance(obs, Mapping): + for key in obs.keys(): + _check_and_init(key) + + return obs + + if isinstance(observables, str) or not isinstance(observables, Sequence): + observables = (observables,) + + if len(observables) == 0: + raise ValueError("No observables were provided.") + + return tuple(_check_and_init(obs_array) for obs_array in observables) + + @staticmethod + def _validate_parameter_values( + parameter_values: BindingsArrayLike | Sequence[BindingsArrayLike] | None, + default: Sequence[Sequence[float]] | Sequence[float] | None = None, + ) -> Sequence: + + # Allow optional (if default) + if parameter_values is None: + if default is None: + raise ValueError("No default `parameter_values`, optional input disallowed.") + parameter_values = default + + # Convert single input types to length-1 lists + if _isreal(parameter_values): + parameter_values = [[parameter_values]] + elif isinstance(parameter_values, Mapping): + parameter_values = [parameter_values] + elif isinstance(parameter_values, Sequence) and all( + _isreal(item) for item in parameter_values + ): + parameter_values = [parameter_values] + return tuple(parameter_values) # type: ignore[arg-type] + + @staticmethod + def _cross_validate_circuits_parameter_values( + circuits: tuple[QuantumCircuit, ...], parameter_values: tuple[tuple[float, ...], ...] + ) -> None: + if len(circuits) != len(parameter_values): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of parameter value sets ({len(parameter_values)})." + ) + + @staticmethod + def _cross_validate_circuits_observables( + circuits: tuple[QuantumCircuit, ...], observables: tuple[ObservablesArrayLike, ...] + ) -> None: + if len(circuits) != len(observables): + raise ValueError( + f"The number of circuits ({len(circuits)}) does not match " + f"the number of observables ({len(observables)})." + ) + @classmethod def _program_id(cls) -> str: """Return the program ID.""" diff --git a/qiskit_ibm_runtime/options/__init__.py b/qiskit_ibm_runtime/options/__init__.py index 25eec52bb..baf48c0f9 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -49,6 +49,7 @@ ExecutionOptions EnvironmentOptions SimulatorOptions + TwirlingOptions """ @@ -58,3 +59,4 @@ from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptions +from .twirling_options import TwirlingOptions diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index 01022f7d7..6dc063daa 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -13,13 +13,17 @@ """Execution options.""" from dataclasses import dataclass -from typing import Literal, get_args +from typing import Literal, get_args, Optional +from numbers import Integral from .utils import _flexible ExecutionSupportedOptions = Literal[ "shots", "init_qubits", + "samples", + "shots_per_sample", + "interleave_samples", ] @@ -29,14 +33,34 @@ class ExecutionOptions: """Execution options. Args: - shots: Number of repetitions of each circuit, for sampling. Default: 4000. + shots: Number of repetitions of each circuit, for sampling. Default: 4096. init_qubits: Whether to reset the qubits to the ground state for each shot. Default: ``True``. + + samples: The number of samples of each measurement circuit to run. This + is used when twirling or resilience levels 1, 2, 3. If None it will + be calculated automatically based on the ``shots`` and + ``shots_per_sample`` (if specified). + Default: None + + shots_per_sample: The number of shots per sample of each measurement + circuit to run. This is used when twirling or resilience levels 1, 2, 3. + If None it will be calculated automatically based on the ``shots`` and + ``samples`` (if specified). + Default: None + + interleave_samples: If True interleave samples from different measurement + circuits when running. If False run all samples from each measurement + circuit in order. + Default: False """ - shots: int = 4000 + shots: int = 4096 init_qubits: bool = True + samples: Optional[int] = None + shots_per_sample: Optional[int] = None + interleave_samples: bool = False @staticmethod def validate_execution_options(execution_options: dict) -> None: @@ -47,3 +71,33 @@ def validate_execution_options(execution_options: dict) -> None: for opt in execution_options: if not opt in get_args(ExecutionSupportedOptions): raise ValueError(f"Unsupported value '{opt}' for execution.") + + shots = execution_options.get("shots") + samples = execution_options.get("samples") + shots_per_sample = execution_options.get("shots_per_sample") + if ( + shots is not None + and samples is not None + and shots_per_sample is not None + and shots != samples * shots_per_sample + ): + raise ValueError( + f"If shots ({shots}) != samples ({samples}) * shots_per_sample ({shots_per_sample})" + ) + if shots is not None: + if not isinstance(shots, Integral): + raise ValueError(f"shots must be None or an integer, not {type(shots)}") + if shots < 1: + raise ValueError("shots must be None or >= 1") + if samples is not None: + if not isinstance(samples, Integral): + raise ValueError(f"samples must be None or an integer, not {type(samples)}") + if samples < 1: + raise ValueError("samples must be None or >= 1") + if shots_per_sample is not None: + if not isinstance(shots_per_sample, Integral): + raise ValueError( + f"shots_per_sample must be None or an integer, not {type(shots_per_sample)}" + ) + if shots_per_sample < 1: + raise ValueError("shots_per_sample must be None or >= 1") diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index b9f894c2f..75d4b428b 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -12,21 +12,24 @@ """Primitive options.""" -from typing import Optional, Union, ClassVar -from dataclasses import dataclass, fields, field +from typing import Optional, Union, ClassVar, Literal, get_args, Any +from dataclasses import dataclass, fields, field, asdict import copy import warnings from qiskit.transpiler import CouplingMap -from .utils import _flexible, Dict +from .utils import _flexible, Dict, _remove_dict_none_values from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptions from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions -from .resilience_options import ResilienceOptions +from .resilience_options import ResilienceOptions, _ZneOptions, _PecOptions +from .twirling_options import TwirlingOptions from ..runtime_options import RuntimeOptions +DDSequenceType = Literal[None, "XX", "XpXm", "XY4"] + @_flexible @dataclass @@ -68,6 +71,10 @@ class Options: `system imposed maximum `_. + dynamical_decoupling: Optional, specify a dynamical decoupling sequence to use. + Allowed values are ``"XX"``, ``"XpXm"``, ``"XY4"``. + Default: None + transpilation: Transpilation options. See :class:`TranspilationOptions` for all available options. @@ -86,7 +93,9 @@ class Options: # Defaults for optimization_level and for resilience_level will be assigned # in Sampler/Estimator _DEFAULT_OPTIMIZATION_LEVEL = 3 + _DEFAULT_NOISELESS_OPTIMIZATION_LEVEL = 1 _DEFAULT_RESILIENCE_LEVEL = 1 + _DEFAULT_NOISELESS_RESILIENCE_LEVEL = 0 _MAX_OPTIMIZATION_LEVEL = 3 _MAX_RESILIENCE_LEVEL_ESTIMATOR = 3 _MAX_RESILIENCE_LEVEL_SAMPLER = 1 @@ -96,11 +105,13 @@ class Options: optimization_level: Optional[int] = None resilience_level: Optional[int] = None max_execution_time: Optional[int] = None + dynamical_decoupling: Optional[DDSequenceType] = None transpilation: Union[TranspilationOptions, Dict] = field(default_factory=TranspilationOptions) resilience: Union[ResilienceOptions, Dict] = field(default_factory=ResilienceOptions) execution: Union[ExecutionOptions, Dict] = field(default_factory=ExecutionOptions) environment: Union[EnvironmentOptions, Dict] = field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = field(default_factory=SimulatorOptions) + twirling: Union[TwirlingOptions, Dict] = field(default_factory=TwirlingOptions) _obj_fields: ClassVar[dict] = { "transpilation": TranspilationOptions, @@ -108,6 +119,7 @@ class Options: "environment": EnvironmentOptions, "simulator": SimulatorOptions, "resilience": ResilienceOptions, + "twirling": TwirlingOptions, } @staticmethod @@ -117,39 +129,80 @@ def _get_program_inputs(options: dict) -> dict: Returns: Inputs acceptable by primitives. """ - sim_options = options.get("simulator", {}) - inputs = {} - inputs["transpilation_settings"] = options.get("transpilation", {}) - inputs["transpilation_settings"].update( - { - "optimization_settings": {"level": options.get("optimization_level")}, - "coupling_map": sim_options.get("coupling_map", None), - "basis_gates": sim_options.get("basis_gates", None), - } - ) - if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): - inputs["transpilation_settings"]["coupling_map"] = list( - map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) + + if not options.get("_experimental", True): + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation_settings"] = options.get("transpilation", {}) + inputs["transpilation_settings"].update( + { + "optimization_settings": {"level": options.get("optimization_level")}, + "coupling_map": sim_options.get("coupling_map", None), + "basis_gates": sim_options.get("basis_gates", None), + } ) + if isinstance(inputs["transpilation_settings"]["coupling_map"], CouplingMap): + inputs["transpilation_settings"]["coupling_map"] = list( + map(list, inputs["transpilation_settings"]["coupling_map"].get_edges()) + ) - inputs["resilience_settings"] = options.get("resilience", {}) - inputs["resilience_settings"].update({"level": options.get("resilience_level")}) - inputs["run_options"] = options.get("execution") - inputs["run_options"].update( - { - "noise_model": sim_options.get("noise_model", None), - "seed_simulator": sim_options.get("seed_simulator", None), - } - ) + inputs["resilience_settings"] = options.get("resilience", {}) + inputs["resilience_settings"].update({"level": options.get("resilience_level")}) + inputs["run_options"] = options.get("execution") + inputs["run_options"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) + + known_keys = list(Options.__dataclass_fields__.keys()) + known_keys.append("image") + # Add additional unknown keys. + for key in options.keys(): + if key not in known_keys: + warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") + inputs[key] = options[key] + inputs["_experimental"] = False + return inputs + else: + sim_options = options.get("simulator", {}) + inputs = {} + inputs["transpilation"] = copy.copy(options.get("transpilation", {})) + inputs["skip_transpilation"] = inputs["transpilation"].pop("skip_transpilation") + coupling_map = sim_options.get("coupling_map", None) + if isinstance(coupling_map, CouplingMap): + coupling_map = list(map(list, coupling_map.get_edges())) + inputs["transpilation"].update( + { + "optimization_level": options.get("optimization_level"), + "coupling_map": coupling_map, + "basis_gates": sim_options.get("basis_gates", None), + } + ) - known_keys = list(Options.__dataclass_fields__.keys()) - known_keys.append("image") - # Add additional unknown keys. - for key in options.keys(): - if key not in known_keys: - warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") - inputs[key] = options[key] - return inputs + inputs["resilience_level"] = options.get("resilience_level") + inputs["resilience"] = options.get("resilience", {}) + inputs["twirling"] = options.get("twirling", {}) + + inputs["execution"] = options.get("execution") + inputs["execution"].update( + { + "noise_model": sim_options.get("noise_model", None), + "seed_simulator": sim_options.get("seed_simulator", None), + } + ) + + known_keys = list(Options.__dataclass_fields__.keys()) + known_keys.append("image") + # Add additional unknown keys. + for key in options.keys(): + if key not in known_keys: + warnings.warn(f"Key '{key}' is an unrecognized option. It may be ignored.") + inputs[key] = options[key] + + inputs["_experimental"] = True + return inputs @staticmethod def validate_options(options: dict) -> None: @@ -165,6 +218,15 @@ def validate_options(options: dict) -> None: f"optimization_level can only take the values " f"{list(range(Options._MAX_OPTIMIZATION_LEVEL + 1))}" ) + + dd_seq = options.get("dynamical_decoupling") + if dd_seq not in get_args(DDSequenceType): + raise ValueError( + f"Unsupported value '{dd_seq}' for dynamical_decoupling. " + f"Allowed values are {get_args(DDSequenceType)}" + ) + + TwirlingOptions.validate_twirling_options(options.get("twirling")) ResilienceOptions.validate_resilience_options(options.get("resilience")) TranspilationOptions.validate_transpilation_options(options.get("transpilation")) execution_time = options.get("max_execution_time") @@ -202,7 +264,11 @@ def _get_runtime_options(options: dict) -> dict: return out @staticmethod - def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dict: + def _merge_options( + old_options: dict, + new_options: Optional[dict] = None, + allowed_none_keys: Optional[set] = None, + ) -> dict: """Merge current options with the new ones. Args: @@ -211,6 +277,7 @@ def _merge_options(old_options: dict, new_options: Optional[dict] = None) -> dic Returns: Merged dictionary. """ + allowed_none_keys = allowed_none_keys or set() def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> None: if not new and not matched: @@ -222,9 +289,13 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non matched = new.pop(key, {}) _update_options(val, new, matched) elif key in new.keys(): - old[key] = new.pop(key) + new_val = new.pop(key) + if new_val is not None or key in allowed_none_keys: + old[key] = new_val elif key in matched.keys(): - old[key] = matched.pop(key) + new_val = matched.pop(key) + if new_val is not None or key in allowed_none_keys: + old[key] = new_val # Add new keys. for key, val in matched.items(): @@ -242,3 +313,120 @@ def _update_options(old: dict, new: dict, matched: Optional[dict] = None) -> Non combined.update(new_options_copy) return combined + + @classmethod + def _merge_options_with_defaults( + cls, + primitive_options: dict, + overwrite_options: Optional[dict] = None, + is_simulator: bool = False, + ) -> dict: + def _get_merged_value(name: str, first: dict = None, second: dict = None) -> Any: + first = first or overwrite_options + second = second or primitive_options + return first.get(name) or second.get(name) + + # 1. Determine optimization and resilience levels + optimization_level = _get_merged_value("optimization_level") + resilience_level = _get_merged_value("resilience_level") + noise_model = _get_merged_value( + "noise_model", + first=overwrite_options.get("simulator", {}), + second=primitive_options.get("simulator", {}), + ) + if optimization_level is None: + optimization_level = ( + cls._DEFAULT_NOISELESS_OPTIMIZATION_LEVEL + if (is_simulator and noise_model is None) + else cls._DEFAULT_OPTIMIZATION_LEVEL + ) + if resilience_level is None: + resilience_level = ( + cls._DEFAULT_NOISELESS_RESILIENCE_LEVEL + if (is_simulator and noise_model is None) + else cls._DEFAULT_RESILIENCE_LEVEL + ) + + # 2. Determine the default resilience options + if resilience_level not in _DEFAULT_RESILIENCE_LEVEL_OPTIONS: + raise ValueError(f"resilience_level {resilience_level} is not a valid value.") + default_options = asdict(_DEFAULT_RESILIENCE_LEVEL_OPTIONS[resilience_level]) + default_options["optimization_level"] = optimization_level + + # HACK: To allow certain values to be explicitly updated with None + none_keys = {"shots", "samples", "shots_per_sample", "zne_extrapolator", "pec_max_overhead"} + + # 3. Merge in primitive options. + final_options = Options._merge_options( + default_options, primitive_options, allowed_none_keys=none_keys + ) + + # 4. Merge in overwrites. + final_options = Options._merge_options( + final_options, overwrite_options, allowed_none_keys=none_keys + ) + + # 5. Remove Nones + _remove_dict_none_values(final_options, allowed_none_keys=none_keys) + + return final_options + + +@dataclass(frozen=True) +class _ResilienceLevel0Options: + resilience_level: int = 0 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=False, zne_mitigation=False, pec_mitigation=False + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=False, measure=False) + ) + + +@dataclass(frozen=True) +class _ResilienceLevel1Options: + resilience_level: int = 1 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, zne_mitigation=False, pec_mitigation=False + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=False, measure=True, strategy="active-accum") + ) + + +@dataclass(frozen=True) +class _ResilienceLevel2Options: + resilience_level: int = 2 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, pec_mitigation=False, **asdict(_ZneOptions()) + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active-accum") + ) + + +@dataclass(frozen=True) +class _ResilienceLevel3Options: + resilience_level: int = 3 + resilience: ResilienceOptions = field( + default_factory=lambda: ResilienceOptions( + measure_noise_mitigation=True, zne_mitigation=False, **asdict(_PecOptions()) + ) + ) + twirling: TwirlingOptions = field( + default_factory=lambda: TwirlingOptions(gates=True, measure=True, strategy="active") + ) + + +_DEFAULT_RESILIENCE_LEVEL_OPTIONS = { + 0: _ResilienceLevel0Options(), + 1: _ResilienceLevel1Options(), + 2: _ResilienceLevel2Options(), + 3: _ResilienceLevel3Options(), +} diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 866ce0741..6ace12515 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -12,11 +12,11 @@ """Resilience options.""" -from typing import Sequence, Literal, get_args +from typing import Sequence, Literal, get_args, Union from dataclasses import dataclass from .utils import _flexible -from ..utils.deprecation import issue_deprecation_msg +from ..utils.deprecation import issue_deprecation_msg, deprecate_arguments ResilienceSupportedOptions = Literal[ "noise_amplifier", @@ -36,6 +36,17 @@ "QuarticExtrapolator", ] +ZneExtrapolatorType = Literal[ + None, + "exponential", + "double_exponential", + "linear", + "polynomial_degree_1", + "polynomial_degree_2", + "polynomial_degree_3", + "polynomial_degree_4", +] + @_flexible @dataclass @@ -43,39 +54,93 @@ class ResilienceOptions: """Resilience options. Args: - noise_factors: An list of real valued noise factors that determine by what amount the - circuits' noise is amplified. + noise_factors (DEPRECATED): An list of real valued noise factors that determine + by what amount the circuits' noise is amplified. Only applicable for ``resilience_level=2``. - Default: (1, 3, 5). + Default: (1, 3, 5) if resilience level is 2. Otherwise ``None``. noise_amplifier (DEPRECATED): A noise amplification strategy. One of ``"TwoQubitAmplifier"``, ``"GlobalFoldingAmplifier"``, ``"LocalFoldingAmplifier"``, ``"CxAmplifier"``. Only applicable for ``resilience_level=2``. - Default: "TwoQubitAmplifier". + Default: "TwoQubitAmplifier" if resilience level is 2. Otherwise ``None``. - extrapolator: An extrapolation strategy. One of ``"LinearExtrapolator"``, + extrapolator (DEPRECATED): An extrapolation strategy. One of ``"LinearExtrapolator"``, ``"QuadraticExtrapolator"``, ``"CubicExtrapolator"``, ``"QuarticExtrapolator"``. Note that ``"CubicExtrapolator"`` and ``"QuarticExtrapolator"`` require more noise factors than the default. Only applicable for ``resilience_level=2``. - Default: "LinearExtrapolator". + Default: ``LinearExtrapolator`` if resilience level is 2. Otherwise ``None``. + + measure_noise_mitigation: Whether to enable measurement error mitigation method. + By default, this is enabled for resilience level 1, 2, and 3 (when applicable). + + zne_mitigation: Whether to turn on Zero Noise Extrapolation error mitigation method. + By default, ZNE is enabled for resilience level 2. + + zne_noise_factors: An list of real valued noise factors that determine by what amount the + circuits' noise is amplified. + Only applicable if ZNE is enabled. + Default: (1, 3, 5). + + zne_extrapolator: An extrapolation strategy. One or more of ``"multi_exponential"``, + ``"single_exponential"``, ``"double_exponential"``, ``"linear"``. + Only applicable if ZNE is enabled. + Default: ``("exponential, "linear")`` + + zne_stderr_threshold: A standard error threshold for accepting the ZNE result of Pauli basis + expectation values when using ZNE mitigation. Any extrapolator model resulting an larger + standard error than this value, or mean that is outside of the allowed range and threshold + will be rejected. If all models are rejected the result for the lowest noise factor is + used for that basis term. + Only applicable if ZNE is enabled. + Default: 0.25 + + pec_mitigation: Whether to turn on Probabilistic Error Cancellation error mitigation method. + By default, PEC is enabled for resilience level 3. + + pec_max_overhead: Specify a maximum sampling overhead for the PEC sampling noise model. + If None the full learned model will be sampled from, otherwise if the learned noise + model has a sampling overhead greater than this value it will be scaled down to + implement partial PEC with a scaled noise model corresponding to the maximum + sampling overhead. + Only applicable if PEC is enabled. + Default: 100 """ noise_amplifier: NoiseAmplifierType = None - noise_factors: Sequence[float] = (1, 3, 5) - extrapolator: ExtrapolatorType = "LinearExtrapolator" + noise_factors: Sequence[float] = None + extrapolator: ExtrapolatorType = None + + # Measurement error mitigation + measure_noise_mitigation: bool = None + + # ZNE + zne_mitigation: bool = None + zne_noise_factors: Sequence[float] = None + zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( + "exponential", + "linear", + ) + zne_stderr_threshold: float = None + + # PEC + pec_mitigation: bool = None + pec_max_overhead: float = None @staticmethod def validate_resilience_options(resilience_options: dict) -> None: """Validate that resilience options are legal. + Raises: ValueError: if any resilience option is not supported ValueError: if noise_amplifier is not in NoiseAmplifierType. ValueError: if extrapolator is not in ExtrapolatorType. ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. + TypeError: if an input value has an invalid type. """ - if resilience_options.get("noise_amplifier", None) is not None: + noise_amplifier = resilience_options.get("noise_amplifier") + if noise_amplifier is not None: issue_deprecation_msg( msg="The 'noise_amplifier' resilience option is deprecated", version="0.12.0", @@ -85,22 +150,32 @@ def validate_resilience_options(resilience_options: dict) -> None: "Refer to https://github.com/qiskit-community/prototype-zne " "for global folding amplification in ZNE.", ) + if noise_amplifier not in get_args(NoiseAmplifierType): + raise ValueError( + f"Unsupported value {noise_amplifier} for noise_amplifier. " + f"Supported values are {get_args(NoiseAmplifierType)}" + ) - for opt in resilience_options: - if not opt in get_args(ResilienceSupportedOptions): - raise ValueError(f"Unsupported value '{opt}' for resilience.") - noise_amplifier = resilience_options.get("noise_amplifier") or "TwoQubitAmplifier" - if not noise_amplifier in get_args(NoiseAmplifierType): - raise ValueError( - f"Unsupported value {noise_amplifier} for noise_amplifier. " - f"Supported values are {get_args(NoiseAmplifierType)}" + if resilience_options.get("noise_factors", None) is not None: + deprecate_arguments( + deprecated="noise_factors", + version="0.13.0", + remedy="Please use 'zne_noise_factors' instead.", ) + extrapolator = resilience_options.get("extrapolator") - if not extrapolator in get_args(ExtrapolatorType): - raise ValueError( - f"Unsupported value {extrapolator} for extrapolator. " - f"Supported values are {get_args(ExtrapolatorType)}" + if extrapolator is not None: + deprecate_arguments( + deprecated="extrapolator", + version="0.13.0", + remedy="Please use 'zne_extrapolator' instead.", ) + if extrapolator not in get_args(ExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for extrapolator. " + f"Supported values are {get_args(ExtrapolatorType)}" + ) + if ( extrapolator == "QuarticExtrapolator" and len(resilience_options.get("noise_factors")) < 5 @@ -108,3 +183,75 @@ def validate_resilience_options(resilience_options: dict) -> None: raise ValueError("QuarticExtrapolator requires at least 5 noise_factors.") if extrapolator == "CubicExtrapolator" and len(resilience_options.get("noise_factors")) < 4: raise ValueError("CubicExtrapolator requires at least 4 noise_factors.") + + # Validation of new ZNE options + if resilience_options.get("zne_mitigation"): + # Validate extrapolator + extrapolator = resilience_options.get("zne_extrapolator") + if isinstance(extrapolator, str): + extrapolator = (extrapolator,) + if extrapolator is not None: + for extrap in extrapolator: + if extrap not in get_args(ZneExtrapolatorType): + raise ValueError( + f"Unsupported value {extrapolator} for zne_extrapolator. " + f"Supported values are {get_args(ZneExtrapolatorType)}" + ) + + # Validation of noise factors + factors = resilience_options.get("zne_noise_factors") + if not isinstance(factors, (list, tuple)): + raise TypeError( + f"zne_noise_factors option value must be a sequence, not {type(factors)}" + ) + if any(i <= 0 for i in factors): + raise ValueError("zne_noise_factors` option value must all be non-negative") + if len(factors) < 1: + raise ValueError("zne_noise_factors cannot be empty") + if extrapolator is not None: + required_factors = { + "exponential": 2, + "double_exponential": 4, + "linear": 2, + "polynomial_degree_1": 2, + "polynomial_degree_2": 3, + "polynomial_degree_3": 4, + "polynomial_degree_4": 5, + } + for extrap in extrapolator: + if len(factors) < required_factors[extrap]: + raise ValueError( + f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" + ) + + # Validation of threshold + threshold = resilience_options.get("zne_stderr_threshold") + if threshold is not None and threshold <= 0: + raise ValueError("Invalid zne_stderr_threshold option value must be > 0") + + if resilience_options.get("pec_mitigation"): + if resilience_options.get("zne_mitigation"): + raise ValueError( + "pec_mitigation and zne_mitigation`options cannot be " + "simultaneously enabled. Set one of them to False." + ) + max_overhead = resilience_options.get("pec_max_overhead") + if max_overhead is not None and max_overhead < 1: + raise ValueError("pec_max_overhead must be None or >= 1") + + +@dataclass(frozen=True) +class _ZneOptions: + zne_mitigation: bool = True + zne_noise_factors: Sequence[float] = (1, 3, 5) + zne_extrapolator: Union[ZneExtrapolatorType, Sequence[ZneExtrapolatorType]] = ( + "exponential", + "linear", + ) + zne_stderr_threshold: float = 0.25 + + +@dataclass(frozen=True) +class _PecOptions: + pec_mitigation: bool = True + pec_max_overhead: float = 100 diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py new file mode 100644 index 000000000..13fbc9af4 --- /dev/null +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -0,0 +1,80 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2023. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Twirling options.""" + +from typing import Literal, get_args +from dataclasses import dataclass + +from .utils import _flexible + + +TwirlingStrategyType = Literal[ + None, + "active", + "active-accum", + "active-circuit", + "all", +] + + +@_flexible +@dataclass +class TwirlingOptions: + """Twirling options. + + Args: + gates: Whether to apply 2-qubit gate twirling. + By default, gate twirling is enabled for resilience level >0. + + measure: Whether to apply measurement twirling. + By default, measurement twirling is enabled for resilience level >0. + + strategy: Specify the strategy of twirling qubits in identified layers of + 2-qubit twirled gates. Allowed values are + + - If ``"active"`` only the instruction qubits in each individual twirled + layer will be twirled. + - If ``"active-circuit"`` the union of all instruction qubits in the circuit + will be twirled in each twirled layer. + - If ``"active-accum"`` the union of instructions qubits in the circuit up to + the current twirled layer will be twirled in each individual twirled layer. + - If ``"all"`` all qubits in the input circuit will be twirled in each + twirled layer. + - If None twirling will be disabled. + + Default: ``"active-accum"`` for resilience levels 0, 1, 2. ``"active"`` for + resilience level 3. + """ + + gates: bool = None + measure: bool = None + strategy: TwirlingStrategyType = None + + @staticmethod + def validate_twirling_options(twirling_options: dict) -> None: + """Validate that twirling options are legal. + + Raises: + ValueError: if any resilience option is not supported + ValueError: if noise_amplifier is not in NoiseAmplifierType. + ValueError: if extrapolator is not in ExtrapolatorType. + ValueError: if extrapolator == "QuarticExtrapolator" and number of noise_factors < 5. + ValueError: if extrapolator == "CubicExtrapolator" and number of noise_factors < 4. + """ + if twirling_options.get("gates"): + strategy = twirling_options.get("strategy") + if strategy not in get_args(TwirlingStrategyType): + raise ValueError( + f"Unsupported value {strategy} for twirling strategy. " + f"Supported values are {get_args(TwirlingStrategyType)}" + ) diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 78cd0ad21..58e8a98da 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -12,7 +12,9 @@ """Utility functions for options.""" +from typing import Optional from dataclasses import fields, field, make_dataclass + from ..ibm_backend import IBMBackend @@ -53,6 +55,15 @@ def set_default_error_levels( return options +def _remove_dict_none_values(in_dict: dict, allowed_none_keys: Optional[set] = None) -> None: + allowed_none_keys = allowed_none_keys or set() + for key, val in list(in_dict.items()): + if val is None and key not in allowed_none_keys: + del in_dict[key] + elif isinstance(val, dict): + _remove_dict_none_values(val, allowed_none_keys=allowed_none_keys) + + def _to_obj(cls_, data): # type: ignore if data is None: return cls_() diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index b0a43d47d..81a69ca5a 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -166,13 +166,11 @@ def _validate_options(self, options: dict) -> None: qctrl_validate(options) return - if options.get("resilience_level") and not options.get("resilience_level") in [ - 0, - 1, - ]: + valid_levels = list(range(Options._MAX_RESILIENCE_LEVEL_SAMPLER + 1)) + if options.get("resilience_level") and not options.get("resilience_level") in valid_levels: raise ValueError( - f"resilience_level can only take the values " - f"{list(range(Options._MAX_RESILIENCE_LEVEL_SAMPLER + 1))} in Sampler" + f"resilience_level {options.get('resilience_level')} is not a valid value." + f"It can only take the values {valid_levels} in Sampler." ) Options.validate_options(options) diff --git a/releasenotes/notes/default-resilience-options-7929458af000314f.yaml b/releasenotes/notes/default-resilience-options-7929458af000314f.yaml new file mode 100644 index 000000000..74c9e9dc9 --- /dev/null +++ b/releasenotes/notes/default-resilience-options-7929458af000314f.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + The ``noise_factors`` and ``extrapolator`` options in :class:`qiskit_ibm_runtime.options.ResilienceOptions` + will now default to ``None`` unless ``resilience_level`` is set to 2. + Only options relevant to the resilience level will be set, so when using ``resilience_level`` + 2, ``noise_factors`` will still default to ``(1, 3, 5)`` and ``extrapolator`` will default to + ``LinearExtrapolator``. Additionally, options with a value of ``None`` will no longer be sent to + the server. + diff --git a/test/integration/test_options.py b/test/integration/test_options.py index 66c88a841..eb51247e2 100644 --- a/test/integration/test_options.py +++ b/test/integration/test_options.py @@ -116,6 +116,28 @@ def test_unsupported_input_combinations(self, service): inst.run(circ, observables=obs) self.assertIn("a coupling map is required.", str(exc.exception)) + @run_integration_test + def test_default_resilience_settings(self, service): + """Test that correct default resilience settings are used.""" + circ = QuantumCircuit(1) + obs = SparsePauliOp.from_list([("I", 1)]) + options = Options(resilience_level=2) + backend = service.backends(simulator=True)[0] + with Session(service=service, backend=backend) as session: + inst = Estimator(session=session, options=options) + job = inst.run(circ, observables=obs) + self.assertEqual(job.inputs["resilience_settings"]["noise_factors"], [1, 3, 5]) + self.assertEqual( + job.inputs["resilience_settings"]["extrapolator"], "LinearExtrapolator" + ) + + options = Options(resilience_level=1) + with Session(service=service, backend=backend) as session: + inst = Estimator(session=session, options=options) + job = inst.run(circ, observables=obs) + self.assertIsNone(job.inputs["resilience_settings"]["noise_factors"]) + self.assertIsNone(job.inputs["resilience_settings"]["extrapolator"]) + @production_only @run_integration_test def test_all_resilience_levels(self, service): diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index fdc3e96af..f7751ce6d 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -15,7 +15,10 @@ import warnings from qiskit import QuantumCircuit -from qiskit.quantum_info import SparsePauliOp +from qiskit.quantum_info import SparsePauliOp, Pauli, random_hermitian, random_pauli_list +from qiskit.circuit import Parameter + +import numpy as np from qiskit_ibm_runtime import Estimator, Session, Options @@ -69,3 +72,148 @@ def test_deprecated_noise_amplifier_run(self): estimator.run(self.circuit, self.observables, noise_amplifier="GlobalFoldingAmplifier") self.assertEqual(len(warn), 1, "Deprecation warning not found.") self.assertIn("noise_amplifier", str(warn[-1].message)) + + def test_observable_types_single_circuit(self): + """Test different observable types for a single circuit.""" + all_obs = [ + "IX", + Pauli("YZ"), + SparsePauliOp(["IX", "YZ"]), + {"YZ": 1 + 2j}, + {Pauli("XX"): 1 + 2j}, + random_hermitian((2, 2)), + [["XX", "YY"]], + [[Pauli("XX"), Pauli("YY")]], + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]], + [ + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ] + ], + [ + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ] + ], + [random_pauli_list(2, 2)], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=circuit, observables=obs) + + def test_observable_types_multi_circuits(self): + """Test different observable types for multiple circuits.""" + num_qx = 2 + all_obs = [ + ["XX", "YY"], + [Pauli("XX"), Pauli("YY")], + [SparsePauliOp(["XX"]), SparsePauliOp(["YY"])], + [ + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, + ], + [ + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, + ], + [["XX", "YY"]] * num_qx, + [[Pauli("XX"), Pauli("YY")]] * num_qx, + [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]] * num_qx, + [[{"XX": 1 + 2j}, {"YY": 1 + 2j}]] * num_qx, + [[{Pauli("XX"): 1 + 2j}, {Pauli("YY"): 1 + 2j}]] * num_qx, + [random_pauli_list(2, 2)] * num_qx, + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + estimator.run(circuits=[circuit] * num_qx, observables=obs) + + def test_invalid_basis(self): + """Test observable containing invalid basis.""" + all_obs = [ + "JJ", + {"JJ": 1 + 2j}, + [["0J", "YY"]], + [ + [ + {"XX": 1 + 2j}, + {"JJ": 1 + 2j}, + ] + ], + ] + + circuit = QuantumCircuit(2) + estimator = Estimator(backend=get_mocked_backend()) + for obs in all_obs: + with self.subTest(obs=obs): + with self.assertRaises(ValueError): + estimator.run(circuits=circuit, observables=obs) + + def test_single_parameter_single_circuit(self): + """Test single parameter for a single cirucit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, 0) + + param_vals = [ + np.pi, + [np.pi], + [[np.pi]], + np.array([np.pi]), + np.array([[np.pi]]), + [np.array([np.pi])], + [[[np.pi], [np.pi / 2]]], + {theta: np.pi}, + [{theta: np.pi}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_single_circuit(self): + """Test multiple parameters for a single circuit.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi]], + np.array([[np.pi, np.pi]]), + [np.array([np.pi, np.pi])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]]], + {theta: [np.pi, np.pi / 2]}, + {theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, + [{theta: [np.pi, np.pi / 2]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=circuit, observables="ZZ", parameter_values=val) + + def test_multiple_parameters_multiple_circuits(self): + """Test multiple parameters for multiple circuits.""" + theta = Parameter("θ") + circuit = QuantumCircuit(2) + circuit.rz(theta, [0, 1]) + + param_vals = [ + [[np.pi, np.pi], [0.5, 0.5]], + [np.array([np.pi, np.pi]), np.array([0.5, 0.5])], + [[[np.pi, np.pi], [np.pi / 2, np.pi / 2]], [[0.5, 0.5], [0.1, 0.1]]], + [{theta: [[np.pi, np.pi / 2], [np.pi / 4, np.pi / 8]]}, {theta: [0.5, 0.5]}], + ] + + estimator = Estimator(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + estimator.run(circuits=[circuit] * 2, observables=["ZZ"] * 2, parameter_values=val) diff --git a/test/unit/test_ibm_primitives.py b/test/unit/test_ibm_primitives.py index 27e020079..6d5f2814f 100644 --- a/test/unit/test_ibm_primitives.py +++ b/test/unit/test_ibm_primitives.py @@ -13,7 +13,6 @@ """Tests for primitive classes.""" import sys -import copy import os from unittest.mock import MagicMock, patch import warnings @@ -81,9 +80,7 @@ def test_dict_options(self): for options in options_vars: with self.subTest(primitive=cls, options=options): inst = cls(session=MagicMock(spec=MockSession), options=options) - expected = asdict(Options()) - self._update_dict(expected, copy.deepcopy(options)) - self.assertDictEqual(expected, inst.options.__dict__) + self.assertTrue(dict_paritally_equal(inst.options.__dict__, options)) def test_backend_in_options(self): """Test specifying backend in options.""" @@ -308,10 +305,10 @@ def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) options_vars = [ - (Options(resilience_level=1), {"resilience_settings": {"level": 1}}), + (Options(resilience_level=1), {"resilience_level": 1}), ( Options(optimization_level=3), - {"transpilation_settings": {"optimization_settings": {"level": 3}}}, + {"transpilation": {"optimization_level": 3}}, ), ( { @@ -319,8 +316,8 @@ def test_run_default_options(self): "execution": {"shots": 100}, }, { - "transpilation_settings": {"initial_layout": [1, 2]}, - "run_options": {"shots": 100}, + "transpilation": {"initial_layout": [1, 2]}, + "execution": {"shots": 100}, }, ), ] @@ -354,9 +351,9 @@ def test_run_updated_default_options(self): self._assert_dict_partially_equal( inputs, { - "resilience_settings": {"level": 1}, - "transpilation_settings": {"optimization_settings": {"level": 2}}, - "run_options": {"shots": 99}, + "resilience_level": 1, + "transpilation": {"optimization_level": 2}, + "execution": {"shots": 99}, }, ) @@ -364,17 +361,17 @@ def test_run_overwrite_options(self): """Test run using overwritten options.""" session = MagicMock(spec=MockSession) options_vars = [ - ({"resilience_level": 1}, {"resilience_settings": {"level": 1}}), - ({"shots": 200}, {"run_options": {"shots": 200}}), + ({"resilience_level": 1}, {"resilience_level": 1}), + ({"shots": 200}, {"execution": {"shots": 200}}), ( {"optimization_level": 3}, - {"transpilation_settings": {"optimization_settings": {"level": 3}}}, + {"transpilation": {"optimization_level": 3}}, ), ( {"initial_layout": [1, 2], "optimization_level": 2}, { - "transpilation_settings": { - "optimization_settings": {"level": 2}, + "transpilation": { + "optimization_level": 2, "initial_layout": [1, 2], } }, @@ -458,7 +455,7 @@ def test_run_multiple_different_options(self): inst.run(self.qx, observables=self.obs, shots=200) kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): - self.assertEqual(kwargs_list[idx][1]["inputs"]["run_options"]["shots"], shots) + self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) self.assertDictEqual(inst.options.__dict__, asdict(Options())) def test_run_same_session(self): @@ -533,10 +530,6 @@ def test_accept_level_1_options(self): # Make sure the values are equal. inst1_options = inst1.options.__dict__ expected_dict = inst2.options.__dict__ - self.assertTrue( - dict_paritally_equal(inst1_options, expected_dict), - f"inst_options={inst1_options}, options={opts}", - ) # Make sure the structure didn't change. self.assertTrue( dict_keys_equal(inst1_options, expected_dict), @@ -561,11 +554,11 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_settings"]["level"], + inputs["resilience_level"], Options._DEFAULT_RESILIENCE_LEVEL, ) @@ -578,11 +571,11 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], Options._DEFAULT_OPTIMIZATION_LEVEL, ) self.assertEqual( - inputs["resilience_settings"]["level"], + inputs["resilience_level"], Options._DEFAULT_RESILIENCE_LEVEL, ) @@ -595,10 +588,10 @@ def test_default_error_levels(self): _, kwargs = session.run.call_args inputs = kwargs["inputs"] self.assertEqual( - inputs["transpilation_settings"]["optimization_settings"]["level"], + inputs["transpilation"]["optimization_level"], 1, ) - self.assertEqual(inputs["resilience_settings"]["level"], 0) + self.assertEqual(inputs["resilience_level"], 0) def test_resilience_options(self): """Test resilience options.""" diff --git a/test/unit/test_options.py b/test/unit/test_options.py index ba39839de..e6432ec7c 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -147,14 +147,14 @@ def test_program_inputs(self): self.assertEqual(len(warn), 2) expected = { - "run_options": {"shots": 100, "noise_model": noise_model}, - "transpilation_settings": { - "optimization_settings": {"level": 1}, - "skip_transpilation": True, + "execution": {"shots": 100, "noise_model": noise_model}, + "skip_transpilation": True, + "transpilation": { + "optimization_level": 1, "initial_layout": [1, 2], }, - "resilience_settings": { - "level": 2, + "resilience_level": 2, + "resilience": { "noise_factors": (0, 2, 4), }, "foo": "foo", @@ -197,6 +197,7 @@ def test_unsupported_options(self): options = { "optimization_level": 1, "resilience_level": 2, + "dynamical_decoupling": "XX", "transpilation": {"initial_layout": [1, 2], "skip_transpilation": True}, "execution": {"shots": 100}, "environment": {"log_level": "DEBUG"}, @@ -205,9 +206,10 @@ def test_unsupported_options(self): "noise_factors": (0, 2, 4), "extrapolator": "LinearExtrapolator", }, + "twirling": {}, } Options.validate_options(options) - for opt in ["resilience", "simulator", "transpilation", "execution"]: + for opt in ["simulator", "transpilation", "execution"]: temp_options = options.copy() temp_options[opt] = {"aaa": "bbb"} with self.assertRaises(ValueError) as exc: @@ -227,7 +229,7 @@ def test_coupling_map_options(self): options = Options() options.simulator.coupling_map = variant inputs = Options._get_program_inputs(asdict(options)) - resulting_cmap = inputs["transpilation_settings"]["coupling_map"] + resulting_cmap = inputs["transpilation"]["coupling_map"] self.assertEqual(coupling_map, set(map(tuple, resulting_cmap))) @data(FakeManila(), FakeNairobiV2()) @@ -311,3 +313,61 @@ def test_qctrl_overrides(self): with self.subTest(msg=f"{option}"): _warn_and_clean_options(option) self.assertEqual(expected_, option) + + def test_merge_with_defaults_overwrite(self): + """Test merge_with_defaults with different overwrite.""" + expected = {"twirling": {"measure": True}} + all_options = [ + ({"twirling": {"measure": True}}, {}), + ({}, {"twirling": {"measure": True}}), + ({"twirling": {"measure": False}}, {"twirling": {"measure": True}}), + ] + + for old, new in all_options: + with self.subTest(old=old, new=new): + old["resilience_level"] = 0 + final = Options._merge_options_with_defaults(old, new) + self.assertTrue(dict_paritally_equal(final, expected)) + self.assertEqual(final["resilience_level"], 0) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_different_level(self): + """Test merge_with_defaults with different resilience level.""" + + old = {"resilience_level": 0} + new = {"resilience_level": 3, "measure_noise_mitigation": False} + final = Options._merge_options_with_defaults(old, new) + self.assertEqual(final["resilience_level"], 3) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertTrue(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_noiseless_simulator(self): + """Test merge_with_defaults with noiseless simulator.""" + + new = {"measure_noise_mitigation": True} + final = Options._merge_options_with_defaults({}, new, is_simulator=True) + self.assertEqual(final["resilience_level"], 0) + self.assertEqual(final["optimization_level"], 1) + res_dict = final["resilience"] + self.assertTrue(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) + + def test_merge_with_defaults_noisy_simulator(self): + """Test merge_with_defaults with noisy simulator.""" + + new = {"measure_noise_mitigation": False} + final = Options._merge_options_with_defaults( + {"simulator": {"noise_model": "foo"}}, new, is_simulator=True + ) + self.assertEqual(final["resilience_level"], 1) + self.assertEqual(final["optimization_level"], 3) + res_dict = final["resilience"] + self.assertFalse(res_dict["measure_noise_mitigation"]) + self.assertFalse(res_dict["zne_mitigation"]) + self.assertFalse(res_dict["pec_mitigation"]) diff --git a/test/utils.py b/test/utils.py index 5ae84b371..4ec9bc0af 100644 --- a/test/utils.py +++ b/test/utils.py @@ -254,4 +254,8 @@ def get_mocked_backend(name: str = "ibm_gotham") -> Any: mock_backend = mock.MagicMock(spec=IBMBackend) mock_backend.name = name mock_backend._instance = None + + mock_service = mock.MagicMock() + mock_backend.service = mock_service + return mock_backend