From 73b33857476a10346542dc95fc595cbdf3c324a7 Mon Sep 17 00:00:00 2001 From: Jessie Yu Date: Wed, 29 Nov 2023 13:48:39 -0500 Subject: [PATCH] Task support in v2 primitives (#1224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add experimental options (#1067) * add options * fix mypy * rename meas err mit * Add additional resilience options * Add additional execution options for twirling * A twirling strategy option and validation * handle default resilience options * add _experimental * Update default resilience options (#1062) * remove default resilience options * add reno * add logic to override default * add test * purge None values from options (cherry picked from commit 76603f2224f859670be2a4f7eaa92c7431307b28) * add finalize options * add tests * Update qiskit_ibm_runtime/options/resilience_options.py * add validation * lint * lint again * lint again * Allow None values for specific options to be passed through * Fix parameter validation, allow computational basis * black * Fix ZneExtrapolatorType validation * lint * lint again * fix mypy * Fix ZNE extrapolator default option * fix level options * black * use _isreal * Disable gate twirling for default lvl 1 opts * Support for legacy options --------- Co-authored-by: Christopher J. Wood Co-authored-by: Kevin Tian Co-authored-by: mberna Co-authored-by: Mariana C Bernagozzi * fix merge issues * add pydantic * black * lint * Fast forward experimental to latest main (#1178) * Remove min execution time check (#1065) * Remove min execution time check * update unit test * remove integration test * Update `max_execution_time` docstrings (#1059) * Update max_execution_time docstrings * add commas * Update qiskit_ibm_runtime/options/options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update qiskit_ibm_runtime/options/options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update qiskit_ibm_runtime/runtime_options.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Update releasenotes/notes/max-execution-time-definition-196cb6297693c0f2.yaml Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> --------- Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Removed remaining code related to Schedules (#1068) Co-authored-by: Kevin Tian * Enable datetime parameter for backend properties (#1070) * enable datetime param for backend properties * add test & reno * improve test * Update default resilience options (#1062) * remove default resilience options * add reno * add logic to override default * add test * purge None values from options * fix test (#1074) * Prepare release 0.12.1 (#1075) * Update main branch veresion 0.12.2 (#1076) * use ibmq_qasm_simulator (#1078) * Add reason code to error message (#1072) * Add reason code to error message * add reno * Remove importing PauliSumOp, which is deprecated. (#1079) --------- Co-authored-by: Jake Lishman Co-authored-by: Kevin Tian * Adjusts default value for optimization_level (#1082) * add instance info (#1083) * add instance info * Add cloud note * Update README.md Co-authored-by: Kevin Tian --------- Co-authored-by: Kevin Tian * Fix links to options in README.md (#1084) * Remove auth parameter (#1077) * Removed auth parameter * Removed calls to migrate() * black and lint --------- Co-authored-by: Kevin Tian * Remove opflow and algorithms from serialization tests (#1085) * Remove opflow from tests * Re-add test for PauliSumOp * Fix lint * Fix black --------- Co-authored-by: Kevin Tian * RuntimeJobTimeoutError should inherit from JobTimeoutError (#1090) * RuntimeJobTimeoutError inherits from JobTimeoutError * black --------- Co-authored-by: Kevin Tian * Allow user to define a default account as an environment variable (#1018) * Allow user to define a default account as an environment variable * Fixed test * Fixed mistaken paste * Cleaned up test * Moved test to TestAccountManager * Added ability to define default channel in save_account * Cleaned up code, fixed bugs * Changed name of parameter * Added test. Cleaned up code surrounding preferences of channel selection * black and lint * Fixed bug when json file was empty * Code cleanup and documentation * Documentation * Removed channel from condition, because unnecessary * changed default_channel to default_account * Changed saving and getting default channel to default account * black * Documentation * Release notes * Reverted diff that was unnecessary --------- Co-authored-by: Kevin Tian * Skip test_job_logs (#1094) * Update session max_time docstring (#1089) * Update session max_time docstring * Update qiskit_ibm_runtime/session.py Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> --------- Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> * Fix unit tests against qiskit/main (#1099) * Add measurements to sampler * Remove observables from sampler run --------- Co-authored-by: Kevin Tian * fix iqp link (#1096) Co-authored-by: Kevin Tian * Only return channel strategy supported backends (#1095) * Q-CTRL Backend filters * add reno * fix unit tests * Attempt to fix terra unit tests (#1102) * Attempt to fix terra unit tests * test on my fork * revert coder_qc change * Making account code more generic (#1060) * Making account code more generic by defining subclasses for channel types * Removed channel parameter from _assert_valid_instance * mypy, lint and black * Changed order of decorators * Code cleanup * Documentation fixes * black --------- Co-authored-by: Kevin Tian * Remove old deprecations (#1106) * removing old deprecations * update unit tests * Open plan updates (#1105) * Initial edits * edit * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Jessie comments * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu * Update docs/sessions.rst * Update docs/faqs/max_execution_time.rst --------- Co-authored-by: Jessie Yu * New method to create a new Session object with a given id (#1101) * Added the Session.from_id method * release notes * Added integration test --------- Co-authored-by: Kevin Tian * fix test_session_from_id (#1110) * Warn users if job submitted will exceed quota (#1100) * Warn users if job will exceed quota * update reno * update reno again * Removed support for backend as session parameter (#1091) Co-authored-by: Kevin Tian * Fix minor todos (#1112) * Change qpu complex wording (#1109) * Don't use QPU complex * don't use the word 'limit' * Update docs/faqs/max_execution_time.rst * Update qiskit_ibm_runtime/options/options.py * Update qiskit_ibm_runtime/runtime_job.py * Update releasenotes/notes/0.11/job-cost-estimation-d0ba83dbc95c3f67.yaml * Clarify usage * reclarify reset time * rogue comma * fix whitespace & formatting * job -> system execution time --------- Co-authored-by: Kevin Tian * Add IBM Cloud channel (#1113) * Changes for #1806 * Update docs/faqs/max_execution_time.rst Co-authored-by: Jessie Yu --------- Co-authored-by: Kevin Tian Co-authored-by: Jessie Yu Co-authored-by: Kevin Tian * Prepare release 0.12.2 (#1115) * Update main branch 0.13.0 (#1116) * remove error message test (#1125) * Exceptions should inherit from Terra where suitable (#1120) * Changed RuntimeJobFailureError to inherit from JobError * Changed error type --------- Co-authored-by: Kevin Tian * update docstring & remove max_time (#1137) * Expose new session details (#1119) * add session details method * add reno * support iqp urls * update details mehotd, add status() * update status() to use enum * revert previous change, wait for impl details * update fields returned * update status method * update docstring & reno * update docstrings for both methods * fix docs build * address comments * fix docs build * fix typo * docs build again * fix indent * fix indent again * fix indent 3rd time * Support only LocalFoldingAmplifier as noise_amplifier option (#1093) * Removed support for all noise_amplifier options other than LocalFoldingAmplifier. Removed deprecation warning. * Removed tests that covered deprecation. Updated documentation --------- Co-authored-by: Kevin Tian * Fix target_history date bug (#1143) * fix target_history datetime bug * add reno * add test * Fixed bug when defining shots as int64 (#1151) * Log instance on initialization and when running a job (#1150) * log instances * add _default_instance & fix lint * add test * change var name to current_instance * Move methods into class pages for docs (#1144) * Fix link to `Close a session` (#1152) [1] links to [2] but doesn't go directly to the target section. [1] https://qiskit.org/ecosystem/ibm-runtime/sessions.html#what-happens-when-a-session-ends [2] https://qiskit.org/ecosystem/ibm-runtime/how_to/run_session.html#close-a-session Co-authored-by: Kevin Tian * logging instance test is IQP channel only (#1154) * Update deploy yml (#1148) * Allow users to indicate they are done submitting jobs to a session (#1139) * copy changes over * clean up branch again * address comments, update docstrings * catch appropriate error code * update status code to 404 * Update qiskit_ibm_runtime/session.py Co-authored-by: Jessie Yu * set self._active & remove runtime_session * Update releasenotes/notes/session-accepting-jobs-d7ef6b60c0f5527b.yaml Co-authored-by: Jessie Yu --------- Co-authored-by: Jessie Yu * Prepare release 0.13 (#1157) * Update main branch 0.13.1 (#1158) * Update Sphinx theme (#1156) Co-authored-by: Kevin Tian * Added IBM Quantum logo (#1164) * sessions changes (#1169) * sessions changes * add more ticks * fix link * fix links * Update docs/sessions.rst Co-authored-by: abbycross * Update docs/sessions.rst Co-authored-by: abbycross --------- Co-authored-by: abbycross * Disallow unsupported options (#1108) * Disallow unsupported options * Moved checking of unsupported options to 'flexible' decorator * Modified the test to give TypeError where needed * Removed empty newline * Moved tests from test_ibm_primitives to test_options, because they don't require a primitive * typo * Release note * black and lint * black again * Fixed test failing in CI * Removed _flexible decorator. Moved _post_init into Options class * lint * lint * Fixed bug * lint --------- Co-authored-by: Kevin Tian * fix merge issues * black * lint --------- Co-authored-by: Kevin Tian Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> Co-authored-by: merav-aharoni Co-authored-by: Luciano Bello Co-authored-by: Jake Lishman Co-authored-by: Esteban Ginez <175813+eginez@users.noreply.github.com> Co-authored-by: Kevin Tian Co-authored-by: mberna Co-authored-by: Elena Peña Tapia <57907331+ElePT@users.noreply.github.com> Co-authored-by: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> Co-authored-by: Matt Riedemann Co-authored-by: abbycross * v2 options * estimator options * update test * lint * fix merge issues * black * fix noise model type * lint again * fix header * fix mypy * use v2 as default * cleanup terra options * black * options need not be callable * fix doc * fix tests * fix version * add sampler option * lint * freeze constants * freeze constants * remove is_simulator * make image work * fix merge issues * fix tests * use base classes * lint * clean up unused code * fix tests * lint * add seed_estimator * resolve merge issues * update docstring * lint * 3.8 mapping * v2 result * estimator result version * line * 3.8 support * also 3.8 support * fix tests with no inputs * fix version passing * fix decoder param * disable samplerv2 * disable samplerv2 tests * lint --------- Co-authored-by: Christopher J. Wood Co-authored-by: Kevin Tian Co-authored-by: mberna Co-authored-by: Mariana C Bernagozzi Co-authored-by: Rebecca Dimock <66339736+beckykd@users.noreply.github.com> Co-authored-by: merav-aharoni Co-authored-by: Luciano Bello Co-authored-by: Jake Lishman Co-authored-by: Esteban Ginez <175813+eginez@users.noreply.github.com> Co-authored-by: Kevin Tian Co-authored-by: Elena Peña Tapia <57907331+ElePT@users.noreply.github.com> Co-authored-by: Arnau Casau <47946624+arnaucasau@users.noreply.github.com> Co-authored-by: Matt Riedemann Co-authored-by: abbycross --- qiskit_ibm_runtime/__init__.py | 2 +- qiskit_ibm_runtime/base_primitive.py | 70 ++- qiskit_ibm_runtime/estimator.py | 188 +------- qiskit_ibm_runtime/options/__init__.py | 2 +- .../options/environment_options.py | 8 +- .../options/estimator_options.py | 14 +- .../options/execution_options.py | 14 +- qiskit_ibm_runtime/options/options.py | 24 +- .../options/resilience_options.py | 35 +- qiskit_ibm_runtime/options/sampler_options.py | 10 +- .../options/simulator_options.py | 10 +- .../options/transpilation_options.py | 10 +- .../options/twirling_options.py | 10 +- qiskit_ibm_runtime/options/utils.py | 2 +- .../qiskit/primitives/__init__.py | 9 +- .../qiskit/primitives/base_estimator.py | 169 +------ .../qiskit/primitives/base_primitive.py | 134 ++---- .../qiskit/primitives/base_sampler.py | 97 +--- .../qiskit/primitives/base_task.py | 37 ++ .../qiskit/primitives/bindings_array.py | 455 ++++++++++++++++++ .../qiskit/primitives/estimator_task.py | 97 ++++ .../qiskit/primitives/object_array.py | 94 ++++ .../qiskit/primitives/observables_array.py | 247 ++++++++++ .../qiskit/primitives/options.py | 40 ++ .../qiskit/primitives/sampler_task.py | 82 ++++ qiskit_ibm_runtime/qiskit/primitives/shape.py | 130 +++++ .../qiskit/primitives/task_result.py | 27 ++ qiskit_ibm_runtime/qiskit_runtime_service.py | 2 + qiskit_ibm_runtime/runtime_job.py | 13 +- qiskit_ibm_runtime/sampler.py | 110 +---- .../utils/estimator_result_decoder.py | 14 +- qiskit_ibm_runtime/utils/json.py | 37 ++ test/unit/test_data_serialization.py | 83 +++- test/unit/test_estimator.py | 192 ++++---- test/unit/test_ibm_primitives_v2.py | 302 +++++++----- test/unit/test_options.py | 25 + test/unit/test_sampler.py | 38 +- test/utils.py | 4 +- 38 files changed, 1874 insertions(+), 963 deletions(-) create mode 100644 qiskit_ibm_runtime/qiskit/primitives/base_task.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/bindings_array.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/estimator_task.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/object_array.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/observables_array.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/options.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/sampler_task.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/shape.py create mode 100644 qiskit_ibm_runtime/qiskit/primitives/task_result.py diff --git a/qiskit_ibm_runtime/__init__.py b/qiskit_ibm_runtime/__init__.py index b8acfbd6d..0a8c07d7b 100644 --- a/qiskit_ibm_runtime/__init__.py +++ b/qiskit_ibm_runtime/__init__.py @@ -204,4 +204,4 @@ def result_callback(job_id, result): 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.") +warnings.warn("You are using the experimental-0.2 branch. Stability is not guaranteed.") diff --git a/qiskit_ibm_runtime/base_primitive.py b/qiskit_ibm_runtime/base_primitive.py index 2399e0b83..f6fd81893 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -24,7 +24,8 @@ from qiskit_ibm_provider.session import get_cm_session as get_cm_provider_session -from .options import BaseOptions, Options +from .options import Options +from .options.options import BaseOptions, OptionsV2 from .options.utils import merge_options, set_default_error_levels from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend @@ -32,6 +33,9 @@ from .constants import DEFAULT_DECODERS from .qiskit_runtime_service import QiskitRuntimeService +# TODO: remove when we have real v2 base estimator +from .qiskit.primitives import EstimatorTask, SamplerTask + # pylint: disable=unused-import,cyclic-import from .session import Session @@ -41,7 +45,7 @@ class BasePrimitiveV2(ABC): """Base class for Qiskit Runtime primitives.""" - _OPTIONS_CLASS: type[BaseOptions] = Options + _options_class: Optional[type[BaseOptions]] = OptionsV2 version = 2 def __init__( @@ -75,16 +79,7 @@ def __init__( self._service: QiskitRuntimeService = None self._backend: Optional[IBMBackend] = None - opt_cls = self._OPTIONS_CLASS - if options is None: - self.options = opt_cls() - elif isinstance(options, opt_cls): - self.options = replace(options) - elif isinstance(options, dict): - default_options = opt_cls() - self.options = opt_cls(**merge_options(default_options, options)) - else: - raise ValueError(f"Invalid 'options' type. It can only be a dictionary of {opt_cls}") + self._set_options(options) if isinstance(session, Session): self._session = session @@ -123,36 +118,33 @@ def __init__( "A backend or session must be specified when not using ibm_cloud channel." ) - def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJob: + def _run(self, tasks: Union[list[EstimatorTask], list[SamplerTask]]) -> RuntimeJob: """Run the primitive. Args: - primitive_inputs: Inputs to pass to the primitive. - user_kwargs: Individual options to overwrite the default primitive options. + tasks: Inputs tasks to pass to the primitive. Returns: Submitted job. """ - logger.debug("Merging current options %s with %s", self.options, user_kwargs) - combined = merge_options(self.options, user_kwargs) + primitive_inputs = {"tasks": tasks} + options_dict = asdict(self.options) + self._validate_options(options_dict) + primitive_inputs.update(self._options_class._get_program_inputs(options_dict)) + runtime_options = self._options_class._get_runtime_options(options_dict) - self._validate_options(combined) - - primitive_inputs.update(self._OPTIONS_CLASS._get_program_inputs(combined)) - runtime_options = self._OPTIONS_CLASS._get_runtime_options(combined) + if self._backend and options_dict["transpilation"]["skip_transpilation"]: + for task in tasks: + self._backend.check_faulty(task.circuit) - if self._backend and combined["transpilation"]["skip_transpilation"]: - for circ in primitive_inputs["circuits"]: - self._backend.check_faulty(circ) - - logger.info("Submitting job using options %s", combined) + logger.info("Submitting job using options %s", options_dict) if self._session: return self._session.run( program_id=self._program_id(), inputs=primitive_inputs, options=runtime_options, - callback=combined.get("environment", {}).get("callback", None), + callback=options_dict.get("environment", {}).get("callback", None), result_decoder=DEFAULT_DECODERS.get(self._program_id()), ) @@ -165,7 +157,7 @@ def _run_primitive(self, primitive_inputs: Dict, user_kwargs: Dict) -> RuntimeJo program_id=self._program_id(), options=runtime_options, inputs=primitive_inputs, - callback=combined.get("environment", {}).get("callback", None), + callback=options_dict.get("environment", {}).get("callback", None), result_decoder=DEFAULT_DECODERS.get(self._program_id()), ) @@ -178,15 +170,19 @@ def session(self) -> Optional[Session]: """ return self._session - def set_options(self, **fields: Any) -> None: - """Set options values for the sampler. - - Args: - **fields: The fields to update the options - """ - self.options = self._OPTIONS_CLASS( # pylint: disable=attribute-defined-outside-init - **merge_options(self.options, fields) - ) + def _set_options(self, options: Optional[Union[Dict, BaseOptions]] = None) -> None: + """Set options.""" + if options is None: + self._options = self._options_class() + elif isinstance(options, dict): + default_options = self._options_class() + self.options = self._options_class(**merge_options(default_options, options)) + elif isinstance(options, self._options_class): + self._options = replace(options) + else: + raise TypeError( + f"Invalid 'options' type. It can only be a dictionary of {self._options_class}" + ) @abstractmethod def _validate_options(self, options: dict) -> None: diff --git a/qiskit_ibm_runtime/estimator.py b/qiskit_ibm_runtime/estimator.py index 9b6abb1f1..a0ce8a29f 100644 --- a/qiskit_ibm_runtime/estimator.py +++ b/qiskit_ibm_runtime/estimator.py @@ -14,20 +14,13 @@ from __future__ import annotations import os -from typing import Optional, Dict, Sequence, Any, Union, Mapping +from typing import Optional, Dict, Sequence, Any, Union import logging import typing -import numpy as np -from numpy.typing import ArrayLike - from qiskit.circuit import QuantumCircuit 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 from .runtime_job import RuntimeJob from .ibm_backend import IBMBackend @@ -35,7 +28,6 @@ from .options.estimator_options import EstimatorOptions from .base_primitive import BasePrimitiveV1, BasePrimitiveV2 from .utils.qctrl import validate as qctrl_validate -from .utils.deprecation import issue_deprecation_msg # TODO: remove when we have real v2 base estimator from .qiskit.primitives import BaseEstimatorV2 @@ -49,23 +41,6 @@ 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: """Base class for Qiskit Runtime Estimator.""" @@ -118,8 +93,7 @@ class EstimatorV2(BasePrimitiveV2, Estimator, BaseEstimatorV2): print(psi1_H23.result()) """ - _ALLOWED_BASIS: str = "IXYZ01+-rl" - _OPTIONS_CLASS = EstimatorOptions + _options_class = EstimatorOptions version = 2 @@ -149,7 +123,6 @@ def __init__( Raises: NotImplementedError: If "q-ctrl" channel strategy is used. """ - self.options: EstimatorOptions BaseEstimatorV2.__init__(self) Estimator.__init__(self) BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) @@ -157,84 +130,6 @@ def __init__( if self._service._channel_strategy == "q-ctrl": raise NotImplementedError("EstimatorV2 is not supported with q-ctrl channel strategy.") - def run( # pylint: disable=arguments-differ - self, - circuits: QuantumCircuit | Sequence[QuantumCircuit], - observables: Sequence[ObservablesArrayLike] - | ObservablesArrayLike - | Sequence[BaseOperator] - | BaseOperator, - parameter_values: BindingsArrayLike | Sequence[BindingsArrayLike] | None = None, - **kwargs: Any, - ) -> RuntimeJob: - """Submit a request to the estimator primitive. - - Args: - circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or - a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. - - observables: Observable objects. - - parameter_values: Concrete parameters to be bound. - - **kwargs: Individual options to overwrite the default primitive options. - These include the runtime options in :class:`qiskit_ibm_runtime.RuntimeOptions`. - - Returns: - Submitted job. - The result of the job is an instance of :class:`qiskit.primitives.EstimatorResult`. - - Raises: - ValueError: Invalid arguments are given. - """ - # To bypass base class merging of options. - user_kwargs = {"_user_kwargs": kwargs} - - return super().run( - circuits=circuits, - observables=observables, - parameter_values=parameter_values, - **user_kwargs, - ) - - def _run( # pylint: disable=arguments-differ - self, - circuits: Sequence[QuantumCircuit], - observables: Sequence[ObservablesArrayLike], - parameter_values: Sequence[Sequence[float]], - **kwargs: Any, - ) -> RuntimeJob: - """Submit a request to the estimator primitive. - - Args: - circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or - a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. - - observables: A list of observable objects. - - parameter_values: An optional list of concrete parameters to be bound. - - **kwargs: Individual options to overwrite the default primitive options. - These include the runtime options in :class:`~qiskit_ibm_runtime.RuntimeOptions`. - - Returns: - Submitted job - """ - logger.debug( - "Running %s with new options %s", - self.__class__.__name__, - kwargs.get("_user_kwargs", {}), - ) - inputs = { - "circuits": circuits, - "observables": observables, - "parameters": [circ.parameters for circ in circuits], - "parameter_values": parameter_values, - } - return self._run_primitive( - primitive_inputs=inputs, user_kwargs=kwargs.get("_user_kwargs", {}) - ) - def _validate_options(self, options: dict) -> None: """Validate that program inputs (options) are valid @@ -242,8 +137,6 @@ def _validate_options(self, options: dict) -> None: ValidationError: if validation fails. ValueError: if validation fails. """ - self._OPTIONS_CLASS(**options) - # TODO: Server should have different optimization/resilience levels for simulator if ( @@ -257,83 +150,6 @@ def _validate_options(self, options: dict) -> None: "a coupling map is required." ) - @staticmethod - def _validate_observables( - observables: Sequence[ObservablesArrayLike] | ObservablesArrayLike, - ) -> Sequence[ObservablesArrayLike]: - def _check_and_init(obs: Any) -> Any: - if isinstance(obs, str): - if not all(basis in EstimatorV2._ALLOWED_BASIS for basis in obs): - raise ValueError( - f"Invalid character(s) found in observable string. " - f"Allowed basis are {EstimatorV2._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 06bf824a8..0e333a45c 100644 --- a/qiskit_ibm_runtime/options/__init__.py +++ b/qiskit_ibm_runtime/options/__init__.py @@ -57,7 +57,7 @@ from .environment_options import EnvironmentOptions from .execution_options import ExecutionOptionsV1 as ExecutionOptions -from .options import Options, BaseOptions +from .options import Options from .simulator_options import SimulatorOptions from .transpilation_options import TranspilationOptions from .resilience_options import ResilienceOptionsV1 as ResilienceOptions diff --git a/qiskit_ibm_runtime/options/environment_options.py b/qiskit_ibm_runtime/options/environment_options.py index 58c88bfa0..9abbbbf8a 100644 --- a/qiskit_ibm_runtime/options/environment_options.py +++ b/qiskit_ibm_runtime/options/environment_options.py @@ -14,8 +14,8 @@ from typing import Optional, Callable, List, Literal -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass LogLevelType = Literal[ "DEBUG", @@ -26,9 +26,7 @@ ] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class EnvironmentOptions: """Options related to the execution environment. diff --git a/qiskit_ibm_runtime/options/estimator_options.py b/qiskit_ibm_runtime/options/estimator_options.py index abd01ab5d..545179185 100644 --- a/qiskit_ibm_runtime/options/estimator_options.py +++ b/qiskit_ibm_runtime/options/estimator_options.py @@ -10,12 +10,11 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Primitive options.""" +"""Estimator options.""" from typing import Union, Literal -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, field_validator +from pydantic import Field, field_validator from .utils import ( Dict, @@ -29,14 +28,15 @@ from .twirling_options import TwirlingOptions from .options import OptionsV2 +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + DDSequenceType = Literal["XX", "XpXm", "XY4"] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class EstimatorOptions(OptionsV2): - """Options for v2 Estimator. + """Options for EstimatorV2. Args: optimization_level: How much optimization to perform on the circuits. diff --git a/qiskit_ibm_runtime/options/execution_options.py b/qiskit_ibm_runtime/options/execution_options.py index b490d7640..fc29afb7b 100644 --- a/qiskit_ibm_runtime/options/execution_options.py +++ b/qiskit_ibm_runtime/options/execution_options.py @@ -14,15 +14,15 @@ from typing import Union -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict, model_validator, field_validator, ValidationInfo +from pydantic import model_validator, field_validator, ValidationInfo from .utils import Unset, UnsetType, skip_unset_validation +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) + +@primitive_dataclass class ExecutionOptionsV2: """Execution options. @@ -82,9 +82,7 @@ def _validate_options(self) -> "ExecutionOptionsV2": return self -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class ExecutionOptionsV1: """Execution options. diff --git a/qiskit_ibm_runtime/options/options.py b/qiskit_ibm_runtime/options/options.py index 27419acf4..0f6f12b4c 100644 --- a/qiskit_ibm_runtime/options/options.py +++ b/qiskit_ibm_runtime/options/options.py @@ -12,15 +12,14 @@ """Primitive options.""" -from abc import ABC, abstractmethod -from typing import Optional, Union, ClassVar +from abc import abstractmethod +from typing import Optional, Union, ClassVar, Any from dataclasses import dataclass, fields, field import copy import warnings from qiskit.transpiler import CouplingMap -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict +from pydantic import Field from .utils import Dict, _to_obj, UnsetType, Unset, _remove_dict_unset_values, merge_options from .environment_options import EnvironmentOptions @@ -31,11 +30,11 @@ from ..runtime_options import RuntimeOptions # TODO use real base options when available -from ..qiskit.primitives import BasePrimitiveOptions +from ..qiskit.primitives.options import BasePrimitiveOptions, primitive_dataclass @dataclass -class BaseOptions(ABC, BasePrimitiveOptions): +class BaseOptions: """Base options class.""" @staticmethod @@ -68,10 +67,8 @@ def _get_runtime_options(options: dict) -> dict: return out -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) -class OptionsV2(BaseOptions): +@primitive_dataclass +class OptionsV2(BaseOptions, BasePrimitiveOptions): """Base primitive options, used by v2 primitives. Args: @@ -100,6 +97,13 @@ class OptionsV2(BaseOptions): environment: Union[EnvironmentOptions, Dict] = Field(default_factory=EnvironmentOptions) simulator: Union[SimulatorOptions, Dict] = Field(default_factory=SimulatorOptions) + def update(self, **kwargs: Any) -> None: + """Update the options.""" + merged = merge_options(self, kwargs) + for key, val in merged.items(): + if not key.startswith("_"): + setattr(self, key, val) + @staticmethod def _get_program_inputs(options: dict) -> dict: """Convert the input options to program compatible inputs. diff --git a/qiskit_ibm_runtime/options/resilience_options.py b/qiskit_ibm_runtime/options/resilience_options.py index 5237631f5..5f6d2863d 100644 --- a/qiskit_ibm_runtime/options/resilience_options.py +++ b/qiskit_ibm_runtime/options/resilience_options.py @@ -14,11 +14,14 @@ from typing import Sequence, Literal, Union, Optional -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict, field_validator, model_validator +from pydantic import field_validator, model_validator from .utils import Unset, UnsetType, skip_unset_validation +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + + ResilienceSupportedOptions = Literal[ "noise_amplifier", "noise_factors", @@ -45,9 +48,7 @@ ] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class ResilienceOptionsV2: """Resilience options. @@ -65,7 +66,6 @@ class ResilienceOptionsV2: 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 @@ -148,7 +148,7 @@ def _validate_options(self) -> "ResilienceOptionsV2": if isinstance(self.zne_extrapolator, str) else self.zne_extrapolator ) - for extrap in extrapolators: + for extrap in extrapolators: # pylint: disable=not-an-iterable if len(self.zne_noise_factors) < required_factors[extrap]: # type: ignore[arg-type] raise ValueError( f"{extrap} requires at least {required_factors[extrap]} zne_noise_factors" @@ -163,26 +163,7 @@ def _validate_options(self) -> "ResilienceOptionsV2": return self -# @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 - - -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class ResilienceOptionsV1: """Resilience options. diff --git a/qiskit_ibm_runtime/options/sampler_options.py b/qiskit_ibm_runtime/options/sampler_options.py index 03c4d9359..0d0401135 100644 --- a/qiskit_ibm_runtime/options/sampler_options.py +++ b/qiskit_ibm_runtime/options/sampler_options.py @@ -14,8 +14,7 @@ from typing import Union, Literal -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import Field, ConfigDict, field_validator +from pydantic import Field, field_validator from .utils import ( Dict, @@ -28,12 +27,13 @@ from .twirling_options import TwirlingOptions from .options import OptionsV2 +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + DDSequenceType = Literal["XX", "XpXm", "XY4"] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class SamplerOptions(OptionsV2): """Options for v2 Sampler. diff --git a/qiskit_ibm_runtime/options/simulator_options.py b/qiskit_ibm_runtime/options/simulator_options.py index c0dd75d83..373fbf414 100644 --- a/qiskit_ibm_runtime/options/simulator_options.py +++ b/qiskit_ibm_runtime/options/simulator_options.py @@ -19,11 +19,13 @@ from qiskit.utils import optionals from qiskit.transpiler import CouplingMap # pylint: disable=unused-import -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict, field_validator +from pydantic import field_validator from .utils import Unset, UnsetType, skip_unset_validation +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + class NoiseModel: """Fake noise model class for pydantic.""" @@ -31,9 +33,7 @@ class NoiseModel: pass -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class SimulatorOptions: """Simulator options. diff --git a/qiskit_ibm_runtime/options/transpilation_options.py b/qiskit_ibm_runtime/options/transpilation_options.py index 5ae25f161..7d0f3045d 100644 --- a/qiskit_ibm_runtime/options/transpilation_options.py +++ b/qiskit_ibm_runtime/options/transpilation_options.py @@ -14,11 +14,13 @@ from typing import List, Union, Literal -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict, field_validator +from pydantic import field_validator from .utils import Unset, UnsetType, skip_unset_validation +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + LayoutMethodType = Literal[ "trivial", "dense", @@ -34,9 +36,7 @@ ] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class TranspilationOptions: """Transpilation options. diff --git a/qiskit_ibm_runtime/options/twirling_options.py b/qiskit_ibm_runtime/options/twirling_options.py index 97b35871c..615141cbc 100644 --- a/qiskit_ibm_runtime/options/twirling_options.py +++ b/qiskit_ibm_runtime/options/twirling_options.py @@ -14,11 +14,11 @@ from typing import Literal, Union -from pydantic.dataclasses import dataclass as pydantic_dataclass -from pydantic import ConfigDict - from .utils import Unset, UnsetType +# TODO use real base options when available +from ..qiskit.primitives.options import primitive_dataclass + TwirlingStrategyType = Literal[ "active", @@ -28,9 +28,7 @@ ] -@pydantic_dataclass( - config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") -) +@primitive_dataclass class TwirlingOptions: """Twirling options. diff --git a/qiskit_ibm_runtime/options/utils.py b/qiskit_ibm_runtime/options/utils.py index 17aa4bdd9..a2c82a2f9 100644 --- a/qiskit_ibm_runtime/options/utils.py +++ b/qiskit_ibm_runtime/options/utils.py @@ -20,7 +20,7 @@ from ..ibm_backend import IBMBackend if TYPE_CHECKING: - from ..options import BaseOptions + from ..options.options import BaseOptions def set_default_error_levels( diff --git a/qiskit_ibm_runtime/qiskit/primitives/__init__.py b/qiskit_ibm_runtime/qiskit/primitives/__init__.py index f378306f2..cb1136aab 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/__init__.py +++ b/qiskit_ibm_runtime/qiskit/primitives/__init__.py @@ -12,6 +12,9 @@ """Temporary copy of base primitives""" -from .base_estimator import BaseEstimatorV2 # type: ignore -from .base_primitive import BasePrimitiveOptions # type: ignore -from .base_sampler import BaseSamplerV2 # type: ignore +from .base_estimator import BaseEstimatorV2 # type: ignore[attr-defined] +from .base_sampler import BaseSamplerV2 # type: ignore[attr-defined] +from .bindings_array import BindingsArray # type: ignore[attr-defined] +from .observables_array import ObservablesArray # type: ignore[attr-defined] +from .estimator_task import EstimatorTask # type: ignore[attr-defined] +from .sampler_task import SamplerTask # type: ignore[attr-defined] diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py index c04c023f5..f017973ee 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_estimator.py @@ -82,167 +82,38 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Sequence -from typing import Generic, TypeVar -import typing +from typing import Generic, TypeVar, Iterable, Optional from qiskit.circuit import QuantumCircuit -from qiskit.circuit.parametertable import ParameterView from qiskit.providers import JobV1 as Job -from qiskit.quantum_info.operators import SparsePauliOp -from qiskit.quantum_info.operators.base_operator import BaseOperator -from .utils import init_observable -from .base_primitive import BasePrimitiveV2, BasePrimitiveOptions - -if typing.TYPE_CHECKING: - from qiskit.opflow import PauliSumOp +from .estimator_task import EstimatorTask, EstimatorTaskLike +from .base_primitive import BasePrimitiveV2 +from .options import BasePrimitiveOptionsLike T = TypeVar("T", bound=Job) # pylint: disable=invalid-name class BaseEstimatorV2(BasePrimitiveV2, Generic[T]): - """Estimator base class. - - Base class for Estimator that estimates expectation values of quantum circuits and observables. - """ - - __hash__ = None - - def __init__( - self, - *, - options: dict | BasePrimitiveOptions | None = None, - ): - """ - Creating an instance of an Estimator, or using one in a ``with`` context opens a session that - holds resources until the instance is ``close()`` ed or the context is exited. - - Args: - options: Default options. - """ - self._circuits = [] - self._observables = [] - self._parameters = [] - super().__init__(options) - - def run( # pylint: disable=differing-param-doc - self, - circuits: Sequence[QuantumCircuit] | QuantumCircuit, - observables: Sequence[BaseOperator | PauliSumOp | str] | BaseOperator | PauliSumOp | str, - parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None = None, - **run_options, - ) -> T: - """Run the job of the estimation of expectation value(s). - - ``circuits``, ``observables``, and ``parameter_values`` should have the same - length. The i-th element of the result is the expectation of observable - - .. code-block:: python - - obs = observables[i] - - for the state prepared by - - .. code-block:: python - - circ = circuits[i] - - with bound parameters - - .. code-block:: python - - values = parameter_values[i]. - - Args: - circuits: one or more circuit objects. - observables: one or more observable objects. Several formats are allowed; - importantly, ``str`` should follow the string representation format for - :class:`~qiskit.quantum_info.Pauli` objects. - parameter_values: concrete parameters to be bound. - run_options: runtime options used for circuit execution. + """TODO""" - Returns: - The job object of EstimatorResult. + def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): + super().__init__(options=options) - Raises: - TypeError: Invalid argument type given. - ValueError: Invalid argument values given. - """ - # Singular validation - circuits = self._validate_circuits(circuits) - observables = self._validate_observables(observables) - parameter_values = self._validate_parameter_values( - parameter_values, - default=[()] * len(circuits), - ) + def run(self, tasks: EstimatorTaskLike | Iterable[EstimatorTaskLike]) -> T: + """TODO: docstring""" + if isinstance(tasks, EstimatorTask): + tasks = [tasks] + elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): + tasks = [EstimatorTask.coerce(tasks)] + elif tasks is not EstimatorTask: + tasks = [EstimatorTask.coerce(task) for task in tasks] - # Cross-validation - self._cross_validate_circuits_parameter_values(circuits, parameter_values) - self._cross_validate_circuits_observables(circuits, observables) + for task in tasks: + task.validate() - return self._run(circuits, observables, parameter_values, **run_options) + return self._run(tasks) @abstractmethod - def _run( - self, - circuits: tuple[QuantumCircuit, ...], - observables: tuple[SparsePauliOp, ...], - parameter_values: tuple[tuple[float, ...], ...], - **run_options, - ) -> T: - raise NotImplementedError("The subclass of BaseEstimator must implment `_run` method.") - - @staticmethod - def _validate_observables( - observables: Sequence[BaseOperator | PauliSumOp | str] | BaseOperator | PauliSumOp | str, - ) -> tuple[SparsePauliOp, ...]: - if isinstance(observables, str) or not isinstance(observables, Sequence): - observables = (observables,) - if len(observables) == 0: - raise ValueError("No observables were provided.") - return tuple(init_observable(obs) for obs in observables) - - @staticmethod - def _cross_validate_circuits_observables( - circuits: tuple[QuantumCircuit, ...], observables: tuple[BaseOperator | PauliSumOp, ...] - ) -> 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)})." - ) - for i, (circuit, observable) in enumerate(zip(circuits, observables)): - if circuit.num_qubits != observable.num_qubits: - raise ValueError( - f"The number of qubits of the {i}-th circuit ({circuit.num_qubits}) does " - f"not match the number of qubits of the {i}-th observable " - f"({observable.num_qubits})." - ) - - @property - def circuits(self) -> tuple[QuantumCircuit, ...]: - """Quantum circuits that represents quantum states. - - Returns: - The quantum circuits. - """ - return tuple(self._circuits) - - @property - def observables(self) -> tuple[SparsePauliOp, ...]: - """Observables to be estimated. - - Returns: - The observables. - """ - return tuple(self._observables) - - @property - def parameters(self) -> tuple[ParameterView, ...]: - """Parameters of the quantum circuits. - - Returns: - Parameters, where ``parameters[i][j]`` is the j-th parameter of the i-th circuit. - """ - return tuple(self._parameters) + def _run(self, tasks: list[EstimatorTask]) -> T: + pass diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py index b18e6d2ce..a150b9f46 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_primitive.py @@ -14,118 +14,40 @@ """Primitive abstract base class.""" from __future__ import annotations +from typing import Optional -from abc import ABC, abstractmethod -from dataclasses import dataclass -from collections.abc import Sequence +from abc import ABC -import numpy as np - -from qiskit.circuit import QuantumCircuit - - -@dataclass -class BasePrimitiveOptions: - """Base primitive options.""" - - pass +from .options import BasePrimitiveOptions, BasePrimitiveOptionsLike class BasePrimitiveV2(ABC): """Primitive abstract base class.""" version = 2 - - def __init__(self, options: dict | BasePrimitiveOptions | None = None): - pass - - @abstractmethod - def set_options(self, **fields) -> None: - """Set options values for the estimator. - - Args: - **fields: The fields to update the options - """ - raise NotImplementedError() - - @staticmethod - def _validate_circuits( - circuits: Sequence[QuantumCircuit] | QuantumCircuit, - ) -> tuple[QuantumCircuit, ...]: - if isinstance(circuits, QuantumCircuit): - circuits = (circuits,) - elif not isinstance(circuits, Sequence) or not all( - isinstance(cir, QuantumCircuit) for cir in circuits - ): - raise TypeError("Invalid circuits, expected Sequence[QuantumCircuit].") - elif not isinstance(circuits, tuple): - circuits = tuple(circuits) - if len(circuits) == 0: - raise ValueError("No circuits were provided.") - return circuits - - @staticmethod - def _validate_parameter_values( - parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None, - default: Sequence[Sequence[float]] | Sequence[float] | None = None, - ) -> tuple[tuple[float, ...], ...]: - # 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 - - # Support numpy ndarray - if isinstance(parameter_values, np.ndarray): - parameter_values = parameter_values.tolist() - elif isinstance(parameter_values, Sequence): - parameter_values = tuple( - vector.tolist() if isinstance(vector, np.ndarray) else vector - for vector in parameter_values + _options_class: type[BasePrimitiveOptions] = BasePrimitiveOptions + + def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): + self._options: type(self)._options_class + self._set_options(options) + + @property + def options(self) -> BasePrimitiveOptions: + """Options for BaseEstimator""" + return self._options + + @options.setter + def options(self, options: BasePrimitiveOptionsLike) -> None: + self._set_options(options) + + def _set_options(self, options): + if options is None: + self._options = self._options_class() + elif isinstance(options, dict): + self._options = self._options_class(**options) + elif isinstance(options, self._options_class): + self._options = options + else: + raise TypeError( + f"Invalid 'options' type. It can only be a dictionary of {self._options_class}" ) - - # Allow single value - if _isreal(parameter_values): - parameter_values = ((parameter_values,),) - elif isinstance(parameter_values, Sequence) and not any( - isinstance(vector, Sequence) for vector in parameter_values - ): - parameter_values = (parameter_values,) - - # Validation - if ( - not isinstance(parameter_values, Sequence) - or not all(isinstance(vector, Sequence) for vector in parameter_values) - or not all(all(_isreal(value) for value in vector) for vector in parameter_values) - ): - raise TypeError("Invalid parameter values, expected Sequence[Sequence[float]].") - - return tuple(tuple(float(value) for value in vector) for vector in parameter_values) - - @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)})." - ) - for i, (circuit, vector) in enumerate(zip(circuits, parameter_values)): - if len(vector) != circuit.num_parameters: - raise ValueError( - f"The number of values ({len(vector)}) does not match " - f"the number of parameters ({circuit.num_parameters}) for the {i}-th circuit." - ) - - -def _isint(obj: Sequence[Sequence[float]] | Sequence[float] | float) -> bool: - """Check if object is int.""" - int_types = (int, np.integer) - return isinstance(obj, int_types) and not isinstance(obj, bool) - - -def _isreal(obj: Sequence[Sequence[float]] | Sequence[float] | float) -> bool: - """Check if object is a real number: int or float except ``±Inf`` and ``NaN``.""" - float_types = (float, np.floating) - return _isint(obj) or isinstance(obj, float_types) and float("-Inf") < obj < float("Inf") diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py b/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py index db322aea7..940ac3b21 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py +++ b/qiskit_ibm_runtime/qiskit/primitives/base_sampler.py @@ -77,14 +77,14 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Sequence -from typing import Generic, TypeVar +from typing import Generic, TypeVar, Optional, Iterable from qiskit.circuit import QuantumCircuit -from qiskit.circuit.parametertable import ParameterView from qiskit.providers import JobV1 as Job -from .base_primitive import BasePrimitiveV2, BasePrimitiveOptions +from .base_primitive import BasePrimitiveV2 +from .options import BasePrimitiveOptionsLike +from .sampler_task import SamplerTask, SamplerTaskLike T = TypeVar("T", bound=Job) # pylint: disable=invalid-name @@ -95,76 +95,23 @@ class BaseSamplerV2(BasePrimitiveV2, Generic[T]): Base class of Sampler that calculates quasi-probabilities of bitstrings from quantum circuits. """ - __hash__ = None - - def __init__( - self, - *, - options: dict | BasePrimitiveOptions | None = None, - ): - """ - Args: - options: Default options. - """ - self._circuits = [] - self._parameters = [] - super().__init__(options) - - def run( # pylint: disable=differing-param-doc - self, - circuits: Sequence[QuantumCircuit] | QuantumCircuit, - parameter_values: Sequence[Sequence[float]] | Sequence[float] | float | None = None, - **run_options, - ) -> T: - """Run the job of the sampling of bitstrings. - - Args: - circuits: One of more circuit objects. - parameter_values: Parameters to be bound to the circuit. - run_options: Backend runtime options used for circuit execution. - - Returns: - The job object of the result of the sampler. The i-th result corresponds to - ``circuits[i]`` evaluated with parameters bound as ``parameter_values[i]``. - - Raises: - ValueError: Invalid arguments are given. - """ - # Singular validation - circuits = self._validate_circuits(circuits) - parameter_values = self._validate_parameter_values( - parameter_values, - default=[()] * len(circuits), - ) - - # Cross-validation - self._cross_validate_circuits_parameter_values(circuits, parameter_values) - - return self._run(circuits, parameter_values, **run_options) + def __init__(self, options: Optional[BasePrimitiveOptionsLike] = None): + super().__init__(options=options) + + def run(self, tasks: SamplerTaskLike | Iterable[SamplerTaskLike]) -> T: + """TODO: docstring""" + if isinstance(tasks, SamplerTask): + tasks = [tasks] + elif isinstance(tasks, tuple) and isinstance(tasks[0], QuantumCircuit): + tasks = [SamplerTask.coerce(tasks)] + elif tasks is not SamplerTask: + tasks = [SamplerTask.coerce(task) for task in tasks] + + for task in tasks: + task.validate() + + return self._run(tasks) @abstractmethod - def _run( - self, - circuits: tuple[QuantumCircuit, ...], - parameter_values: tuple[tuple[float, ...], ...], - **run_options, - ) -> T: - raise NotImplementedError("The subclass of BaseEstimator must implment `_run` method.") - - @property - def circuits(self) -> tuple[QuantumCircuit, ...]: - """Quantum circuits that represents quantum states. - - Returns: - The quantum circuits. - """ - return tuple(self._circuits) - - @property - def parameters(self) -> tuple[ParameterView, ...]: - """Parameters of the quantum circuits. - - Returns: - Parameters, where ``parameters[i][j]`` is the j-th parameter of the i-th circuit. - """ - return tuple(self._parameters) + def _run(self, tasks: list[SamplerTask]) -> T: + pass diff --git a/qiskit_ibm_runtime/qiskit/primitives/base_task.py b/qiskit_ibm_runtime/qiskit/primitives/base_task.py new file mode 100644 index 000000000..fbd026b25 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/base_task.py @@ -0,0 +1,37 @@ +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from qiskit import QuantumCircuit + + +@dataclass(frozen=True) +class BaseTask: + """Base class for Task""" + + circuit: QuantumCircuit + + def validate(self) -> None: + """Validate the inputs. + + Raises: + TypeError: If input values has an invalid type. + """ + if not isinstance(self.circuit, QuantumCircuit): + raise TypeError("circuit must be QuantumCircuit.") diff --git a/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py b/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py new file mode 100644 index 000000000..4aa5096b8 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py @@ -0,0 +1,455 @@ +# 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. +# type: ignore + +""" +Bindings array class +""" +from __future__ import annotations + +from collections.abc import Iterable +from itertools import chain, product +from typing import Dict, List, Optional, Tuple, Union, Mapping, Sequence + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +from qiskit.circuit import Parameter, QuantumCircuit + +from .shape import ShapedMixin, ShapeInput, shape_tuple + + +class BindingsArray(ShapedMixin): + r"""Stores many possible parameter binding values for a :class:`qiskit.QuantumCircuit`. + + Similar to a ``inspect.BoundArguments`` instance, which stores arguments that can be bound to a + compatible Python function, this class stores both values without names, so that their ordering + is important, as well as values attached to ``qiskit.circuit.Parameters``. However, a dense + rectangular array of possible values is stored for each parameter, so that this class is akin to + an object-array of ``inspect.BoundArguments``. + + The storage format is a list of arrays, ``[vals0, vals1, ...]``, as well as a dictionary of + arrays attached to parameters, ``{params0: kwvals0, ...}``. Crucially, the last dimension of + each array indexes one or more parameters. For example, if the last dimension of ``vals1`` is + 25, then it represents an array of possible binding values for 25 distinct parameters, where its + leading shape is the array :attr:`~.shape` of its binding array. This implies a degeneracy of the + storage format: ``[vals, vals1[..., :10], vals1[..., 10:], ...]`` is exactly equivalent to + ``[vals0, vals1, ...]`` in the bindings it specifies. This complication has been included to + satisfy two competing constraints: + + * Arrays with different dtypes cannot be concatenated into a single array, so that multiple + arrays are required for generality. + * It is extremely convenient to put everything into a small number of big arrays, when + possible. + + .. code-block:: python + + # 0-d array (i.e. only one binding) + BindingsArray([1, 2, 3], {"a": 4, ("b", "c"): [5, 6]}) + + # single array, last index is parameters + BindingsArray(np.empty((10, 10, 100))) + + # multiple arrays, where each last index is parameters. notice that it's smart enough to + # figure out that a missing last dimension corresponds to a single parameter. + BindingsArray( + [np.empty((10, 10, 100)), np.empty((10, 10)), np.empty((10, 10, 20), dtype=complex)], + {("c", "a"): np.empty((10, 10, 2)), "b": np.empty((10, 10))} + ) + """ + + def __init__( + self, + vals: Union[None, ArrayLike, Iterable[ArrayLike]] = None, + kwvals: Union[None, Mapping[Parameter, Iterable[Parameter]], ArrayLike] = None, + shape: Optional[ShapeInput] = None, + ): + """ + The ``shape`` argument does not need to be provided whenever it can unambiguously + be inferred from the provided arrays. Ambiguity arises because an array provided to the + constructor might represent values for either a single parameter, with an implicit missing + last dimension of size ``1``, or for many parameters, where the size of the last dimension + is the number of parameters it is providing values to. This ambiguity can be broken in the + following common ways: + + * Only a single array is provided to ``vals``, and no arrays to ``kwvals``, in which case + it is assumed that the last dimension is over many parameters. + * Multiple arrays are given whose shapes differ only in the last dimension size. + * Some array is given in ``kwvals`` where the key contains multiple + :class:`~.Parameter` s, whose length the last dimension of the array must therefore match. + + Args: + vals: One or more arrays, where the last index of each corresponds to + distinct parameters. If their dtypes allow it, concatenating these + arrays over the last axis is equivalent to providing them separately. + kwvals: A mapping from one or more parameters to arrays of values to bind + them to, where the last axis is over parameters. + shape: The leading shape of every array in these bindings. + + Raises: + ValueError: If all inputs are ``None``. + ValueError: If the shape cannot be automatically inferred from the arrays, or if there + is some inconsistency in the shape of the given arrays. + """ + super().__init__() + + if vals is None: + vals = [] + if kwvals is None: + kwvals = {} + + vals = [vals] if isinstance(vals, np.ndarray) else [np.array(v, copy=False) for v in vals] + kwvals = { + (p,) if isinstance(p, Parameter) else tuple(p): np.array(val, copy=False) + for p, val in kwvals.items() + } + + if shape is None: + # jump through hoops to find out user's intended shape + shape = _infer_shape(vals, kwvals) + + # shape checking, and normalization so that each last index must be over parameters + self._shape = shape_tuple(shape) + for idx, val in enumerate(vals): + vals[idx] = _standardize_shape(val, self._shape) + for parameters, val in kwvals.items(): + val = kwvals[parameters] = _standardize_shape(val, self._shape) + if len(parameters) != val.shape[-1]: + raise ValueError( + f"Length of {parameters} inconsistent with last dimension of {val}" + ) + + self._vals = vals + self._kwvals = kwvals + + def __getitem__(self, args) -> BindingsArray: + # because the parameters live on the last axis, we don't need to do anything special to + # accomodate them because there will always be an implicit slice(None, None, None) + # on all unspecified trailing dimensions + # separately, we choose to not disallow args which touch the last dimension, even though it + # would not be a particularly friendly way to chop parameters + vals = [val[args] for val in self._vals] + kwvals = {params: val[args] for params, val in self._kwvals.items()} + try: + shape = next(chain(vals, kwvals.values())).shape[:-1] + except StopIteration: + shape = () + return BindingsArray(vals, kwvals, shape) + + @property + def kwvals(self) -> Dict[Tuple[Parameter, ...], np.ndarray]: + """The keyword values of this array.""" + return self._kwvals + + @property + def num_parameters(self) -> int: + """The total number of parameters.""" + return sum(val.shape[-1] for val in chain(self.vals, self.kwvals.values())) + + @property + def vals(self) -> List[np.ndarray]: + """The non-keyword values of this array.""" + return self._vals + + def as_array(self, parameters: Optional[Iterable[Parameter]] = None) -> np.ndarray: + """Return the contents of this bindings array as a single NumPy array. + + As with each :attr:`~vals` and :attr:`~kwvals` array, the parameters are indexed along the + last dimension. + + The order of the :attr:`~vals` portion of this bindings array is always preserved, and + always comes first in the returned array, irrespective of whether ``parameters`` are + provided. + + If ``parameters`` are provided, then they determine the order of any :attr:`~kwvals` + present in this bindings array. If :attr:`~vals` are present in addition to :attr:`~kwvals`, + then it is up to the user to ensure that their provided ``parameters`` account for this. + + Parameters: + parameters: Optional parameters that determine the order of the output. + + Returns: + This bindings array as a single NumPy array. + + Raises: + RuntimeError: If these bindings contain multple dtypes. + KeyError: If ``parameters`` are provided that are not a superset of those in this + bindings array. + """ + dtypes = {arr.dtype for arr in self.vals} + dtypes.update(arr.dtype for arr in self.kwvals.values()) + if len(dtypes) > 1: + raise RuntimeError(f"Multiple dtypes ({dtypes}) were found.") + dtype = next(iter(dtypes)) if dtypes else float + + if self.num_parameters == 0 and not self.shape: + # we want this special case to look like a single binding on no parameters + return np.empty((1, 0), dtype=dtype) + + ret = np.empty(shape_tuple(self.shape, self.num_parameters), dtype=dtype) + + # always start by placing the vals in the returned array + pos = 0 + for arr in self.vals: + size = arr.shape[-1] + ret[..., pos : pos + size] = arr + pos += size + + def _param_name(parameter: Union[Parameter, str]) -> str: + """Helper function to handle parameters or strings""" + if isinstance(parameter, Parameter): + return parameter.name + return parameter + + if parameters is None: + # preserve the order of the kwvals + for arr in self.kwvals.values(): + size = arr.shape[-1] + ret[..., pos : pos + size] = arr + pos += size + elif self.kwvals: + # use the order of the provided parameters + parameters = {_param_name(parameter): idx for idx, parameter in enumerate(parameters)} + for arr_params, arr in self.kwvals.items(): + try: + idxs = [parameters[_param_name(param)] for param in arr_params] + except KeyError as ex: + raise KeyError( + "This bindings array has a parameter absent from the provided parameters." + ) from ex + ret[..., idxs] = arr + + return ret + + def bind_at_idx(self, circuit: QuantumCircuit, idx: Tuple[int, ...]) -> QuantumCircuit: + """Return the circuit bound to the values at the provided index. + + Args: + circuit: The circuit to bind. + idx: A tuple of indices, on for each dimension of this array. + + Returns: + The bound circuit. + + Raises: + ValueError: If the index doesn't have the right number of values. + """ + if len(idx) != self.ndim: + raise ValueError(f"Expected {idx} to index all dimensions of {self.shape}") + + flat_vals = (val for vals in self.vals for val in vals[idx]) + + if not self.kwvals: + # special case to avoid constructing a dictionary input + return circuit.assign_parameters(list(flat_vals)) + + parameters = dict(zip(circuit.parameters, flat_vals)) + parameters.update( + (param, val) + for params, vals in self.kwvals.items() + for param, val in zip(params, vals[idx]) + ) + return circuit.assign_parameters(parameters) + + def bind_flat(self, circuit: QuantumCircuit) -> Iterable[QuantumCircuit]: + """Yield a bound circuit for every array index in flattened order. + + Args: + circuit: The circuit to bind. + + Yields: + Bound circuits, in flattened array order. + """ + for idx in product(*map(range, self.shape)): + yield self.bind_at_idx(circuit, idx) + + def bind_all(self, circuit: QuantumCircuit) -> np.ndarray: + """Return an object array of bound circuits with the same shape. + + Args: + circuit: The circuit to bind. + + Returns: + An object array of the same shape containing all bound circuits. + """ + arr = np.empty(self.shape, dtype=object) + for idx in np.ndindex(self.shape): + arr[idx] = self.bind_at_idx(circuit, idx) + return arr + + def ravel(self) -> BindingsArray: + """Return a new :class:`~BindingsArray` with one dimension. + + The returned bindings array has a :attr:`shape` given by ``(size, )``, where the size is the + :attr:`~size` of this bindings array. + + Returns: + A new bindings array. + """ + return self.reshape(self.size) + + def reshape(self, *shape: ShapeInput) -> BindingsArray: + """Return a new :class:`~BindingsArray` with a different shape. + + This results in a new view of the same arrays. + + Args: + *shape: The shape of the returned bindings array. + + Returns: + A new bindings array. + + Raises: + ValueError: If the provided shape has a different product than the current size. + """ + shape = shape_tuple(shape) + + # if we have a minus 1, try and replace it with with a positive number + if any(dim < 0 for dim in shape): + if (subsize := np.prod([dim for dim in shape if dim >= 0]).astype(int)) > 0: + shape = tuple(dim if dim > 0 else self.size // subsize for dim in shape) + + if np.prod(shape).astype(int) != self.size: + raise ValueError(f"Reshaping cannot change the total number of elements. {shape}") + + vals = [val.reshape(shape + (val.shape[-1],)) for val in self._vals] + kwvals = { + params: val.reshape(shape + (val.shape[-1],)) for params, val in self._kwvals.items() + } + return BindingsArray(vals, kwvals, shape) + + @classmethod + def coerce(cls, bindings_array: BindingsArrayLike) -> BindingsArray: + """Coerce BindingsArrayLike into BindingsArray + + Args: + bindings_array: an object to be bindings array. + + Returns: + A coerced bindings array. + + Raises: + TypeError: If input value type is invalid. + """ + if isinstance(bindings_array, BindingsArray): + return bindings_array + if isinstance(bindings_array, Sequence): + bindings_array = np.array(bindings_array) + if bindings_array is None: + bindings_array = cls([], shape=(1,)) + elif isinstance(bindings_array, np.ndarray): + if bindings_array.ndim == 1: + bindings_array = bindings_array.reshape((1, -1)) + bindings_array = cls(bindings_array) + elif isinstance(bindings_array, Mapping): + bindings_array = cls(kwvals=bindings_array) + else: + raise TypeError( + f"Parameter values type {type(bindings_array)} is not BindingsArray-like." + ) + return bindings_array + + def validate(self): + """Validate the consistency in bindings_array.""" + for val in self.vals: + if not isinstance(val, np.ndarray) and val.dtype != float: + raise TypeError( + f"Invalid individual parameter value type {type(val)}, should be a float." + ) + for par, val in self.kwvals.items(): + if not isinstance(val, np.ndarray) and val.dtype != float: + raise TypeError( + f"Invalid individual parameter value type {type(val)} " + f"for parameter {par}, should be a float." + ) + + +def _standardize_shape(val: np.ndarray, shape: Tuple[int, ...]) -> np.ndarray: + """Return ``val`` or ``val[..., None]``. + + Args: + val: The array whose shape to standardize. + shape: The shape to standardize to. + + Returns: + An array with one more dimension than ``len(shape)``, and whose leading dimensions match + ``shape``. + + Raises: + ValueError: If the leading shape of ``val`` does not match the ``shape``. + """ + if val.shape == shape: + val = val[..., None] + elif val.ndim - 1 != len(shape) or val.shape[:-1] != shape: + raise ValueError(f"Array with shape {val.shape} inconsistent with {shape}") + return val + + +def _infer_shape( + vals: List[np.ndarray], kwvals: Dict[Tuple[Parameter, ...], np.ndarray] +) -> Tuple[int, ...]: + """Return a shape tuple that consistently defines the leading dimensions of all arrays. + + Args: + vals: A list of arrays. + kwvals: A mapping from tuples to arrays, where the length of each tuple should match the + last dimension of the corresponding array. + + Returns: + A shape tuple that matches the leading dimension of every array. + + Raises: + ValueError: If this cannot be done unambiguously. + """ + only_possible_shapes = None + + def examine_array(*possible_shapes): + nonlocal only_possible_shapes + if only_possible_shapes is None: + only_possible_shapes = set(possible_shapes) + else: + only_possible_shapes.intersection_update(possible_shapes) + + for parameters, val in kwvals.items(): + if len(parameters) > 1: + # here, the last dimension _has_ to be over parameters + examine_array(val.shape[:-1]) + elif val.shape == () or val.shape == (1,) or val.shape[-1] != 1: + # here, if the last dimension is not 1 or shape is () or (1,) then the shape is the shape + examine_array(val.shape) + else: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(vals) == 1 and len(kwvals) == 0: + examine_array(vals[0].shape[:-1]) + elif len(vals) == 0 and len(kwvals) == 0: + examine_array(()) + else: + for val in vals: + # here, the last dimension could be over parameters or not + examine_array(val.shape, val.shape[:-1]) + + if len(only_possible_shapes) == 1: + return next(iter(only_possible_shapes)) + elif len(only_possible_shapes) == 0: + raise ValueError("Could not find any consistent shape.") + raise ValueError("Could not unambiguously determine the intended shape; specify shape manually") + + +BindingsArrayLike = Union[ + BindingsArray, + NDArray, + Mapping[Parameter, NDArray], + Sequence[NDArray], + None, +] diff --git a/qiskit_ibm_runtime/qiskit/primitives/estimator_task.py b/qiskit_ibm_runtime/qiskit/primitives/estimator_task.py new file mode 100644 index 000000000..e3b3c231e --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/estimator_task.py @@ -0,0 +1,97 @@ +# 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. +# type: ignore + +""" +Estiamtor Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Union, Tuple + +import numpy as np + +from qiskit import QuantumCircuit + +from .base_task import BaseTask +from .bindings_array import BindingsArray, BindingsArrayLike +from .observables_array import ObservablesArray, ObservablesArrayLike +from .shape import ShapedMixin + + +@dataclass(frozen=True) +class EstimatorTask(BaseTask, ShapedMixin): + """Task for Estimator. + Task is composed of triple (circuit, observables, parameter_values). + """ + + observables: ObservablesArray + parameter_values: BindingsArray = BindingsArray([], shape=()) + _shape: tuple[int, ...] = field(init=False) + + def __post_init__(self): + shape = np.broadcast_shapes(self.observables.shape, self.parameter_values.shape) + super().__setattr__("_shape", shape) + + @classmethod + def coerce(cls, task: EstimatorTaskLike) -> EstimatorTask: + """Coerce EstimatorTaskLike into EstimatorTask. + + Args: + task: an object to be estimator task. + + Returns: + A coerced estiamtor task. + + Raises: + ValueError: If input values are invalid. + """ + if isinstance(task, EstimatorTask): + return task + if len(task) != 2 and len(task) != 3: + raise ValueError(f"The length of task must be 2 or 3, but length {len(task)} is given.") + circuit = task[0] + observables = ObservablesArray.coerce(task[1]) + parameter_values = ( + BindingsArray.coerce(task[2]) if len(task) == 3 else BindingsArray([], shape=(1,)) + ) + return cls(circuit=circuit, observables=observables, parameter_values=parameter_values) + + def validate(self) -> None: + """Validate the task.""" + super().validate() + self.observables.validate() + self.parameter_values.validate() + # Cross validate circuits and observables + # for i, observable in enumerate(self.observables): + # num_qubits = len(next(iter(observable))) + + num_qubits = len(next(iter(self.observables.ravel()[0].keys()))) + if self.circuit.num_qubits != num_qubits: + raise ValueError( + f"The number of qubits of the circuit ({self.circuit.num_qubits}) does " + f"not match the number of qubits of the observable ({num_qubits})." + ) + # Cross validate circuits and paramter_values + num_parameters = self.parameter_values.num_parameters + if num_parameters != self.circuit.num_parameters: + raise ValueError( + f"The number of values ({num_parameters}) does not match " + f"the number of parameters ({self.circuit.num_parameters}) for the circuit." + ) + + +EstimatorTaskLike = Union[ + EstimatorTask, Tuple[QuantumCircuit, ObservablesArrayLike, BindingsArrayLike] +] diff --git a/qiskit_ibm_runtime/qiskit/primitives/object_array.py b/qiskit_ibm_runtime/qiskit/primitives/object_array.py new file mode 100644 index 000000000..ed09717c3 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/object_array.py @@ -0,0 +1,94 @@ +# 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. +# type: ignore + +""" +Object ND-array initialization function. +""" + +from typing import Optional, Sequence, Tuple + +import numpy as np +from numpy.typing import ArrayLike + + +def object_array( + arr: ArrayLike, + order: Optional[str] = None, + copy: bool = True, + list_types: Optional[Sequence[type]] = (), +) -> np.ndarray: + """Convert an array-like of objects into an object array. + + .. note:: + + If the objects in the array like input define ``__array__`` methods + this avoids calling them and will instead set the returned array values + to the Python objects themselves. + + Args: + arr: An array-like input. + order: Optional, the order of the returned array (C, F, A, K). If None + the default NumPy ordering of C is used. + copy: If True make a copy of the input if it is already an array. + list_types: Optional, a sequence of types to treat as lists of array + element objects when inferring the array shape from the input. + + Returns: + A NumPy ND-array with ``dtype=object``. + + Raises: + ValueError: If the input cannot be coerced into an object array. + """ + if isinstance(arr, np.ndarray): + if arr.dtype != object or order is not None or copy is True: + arr = arr.astype(object, order=order, copy=copy) + return arr + + shape = _infer_shape(arr, list_types=tuple(list_types)) + obj_arr = np.empty(shape, dtype=object, order=order) + if not shape: + # We call fill here instead of [()] to avoid invoking the + # objects `__array__` method if it has one (eg for Pauli's). + obj_arr.fill(arr) + else: + # For other arrays we need to do some tricks to avoid invoking the + # objects __array__ method by flattening the input and initializing + # using `np.fromiter` which does not invoke `__array__` for object + # dtypes. + def _flatten(nested, k): + if k == 1: + return nested + else: + return [item for sublist in nested for item in _flatten(sublist, k - 1)] + + flattened = _flatten(arr, len(shape)) + if len(flattened) != obj_arr.size: + raise ValueError( + "Input object size does not match the inferred array shape." + " This most likely occurs when the input is a ragged array." + ) + obj_arr.flat = np.fromiter(flattened, dtype=object, count=len(flattened)) + + return obj_arr + + +def _infer_shape(obj: ArrayLike, list_types: Tuple[type, ...] = ()) -> Tuple[int, ...]: + """Infer the shape of an array-like object without casting""" + if isinstance(obj, np.ndarray): + return obj.shape + if not isinstance(obj, (list, *list_types)): + return () + size = len(obj) + if size == 0: + return (size,) + return (size, *_infer_shape(obj[0], list_types=list_types)) diff --git a/qiskit_ibm_runtime/qiskit/primitives/observables_array.py b/qiskit_ibm_runtime/qiskit/primitives/observables_array.py new file mode 100644 index 000000000..07c36fc63 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/observables_array.py @@ -0,0 +1,247 @@ +# 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. +# type: ignore + +""" +ND-Array container class for Estimator observables. +""" +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Mapping as MappingType +from functools import lru_cache +from typing import Iterable, Mapping, Union + +import numpy as np +from numpy.typing import ArrayLike + +from qiskit.quantum_info import Pauli, PauliList, SparsePauliOp + +from .object_array import object_array +from .shape import ShapedMixin + +BasisObservable = Mapping[str, complex] +"""Representation type of a single observable.""" + +BasisObservableLike = Union[ + str, + Pauli, + SparsePauliOp, + Mapping[Union[str, Pauli], complex], + Iterable[Union[str, Pauli, SparsePauliOp]], +] +"""Types that can be natively used to construct a :const:`BasisObservable`.""" + + +class ObservablesArray(ShapedMixin): + """An ND-array of :const:`.BasisObservable` for an :class:`.Estimator` primitive.""" + + ALLOWED_BASIS: str = "IXYZ01+-lr" + """The allowed characters in :const:`BasisObservable` strings.""" + + def __init__( + self, + observables: Union[BasisObservableLike, ArrayLike], + copy: bool = True, + validate: bool = True, + ): + """Initialize an observables array. + + Args: + observables: An array-like of basis observable compatible objects. + copy: Specify the ``copy`` kwarg of the :func:`.object_array` function + when initializing observables. + validate: If True, convert :const:`.BasisObservableLike` input objects + to :const:`.BasisObservable` objects and validate. If False the + input should already be an array-like of valid + :const:`.BasisObservble` objects. + + Raises: + ValueError: If ``validate=True`` and the input observables is not valid. + """ + super().__init__() + if isinstance(observables, ObservablesArray): + observables = observables._array + self._array = object_array(observables, copy=copy, list_types=(PauliList,)) + self._shape = self._array.shape + if validate: + num_qubits = None + for ndi, obs in np.ndenumerate(self._array): + basis_obs = self.format_observable(obs) + basis_num_qubits = len(next(iter(basis_obs))) + if num_qubits is None: + num_qubits = basis_num_qubits + elif basis_num_qubits != num_qubits: + raise ValueError( + "The number of qubits must be the same for all observables in the " + "observables array." + ) + self._array[ndi] = basis_obs + + def __repr__(self): + prefix = f"{type(self).__name__}(" + suffix = f", shape={self.shape})" + array = np.array2string(self._array, prefix=prefix, suffix=suffix, threshold=50) + return prefix + array + suffix + + def tolist(self) -> list: + """Convert to a nested list""" + return self._array.tolist() + + def __array__(self, dtype=None): + """Convert to an Numpy.ndarray""" + if dtype is None or dtype == object: + return self._array + raise ValueError("Type must be 'None' or 'object'") + + def __getitem__(self, args) -> Union[ObservablesArray, BasisObservable]: + item = self._array[args] + if not isinstance(item, np.ndarray): + return item + return ObservablesArray(item, copy=False, validate=False) + + def reshape(self, shape: Union[int, Iterable[int]]) -> "ObservablesArray": + """Return a new array with a different shape. + + This results in a new view of the same arrays. + + Args: + shape: The shape of the returned array. + + Returns: + A new array. + """ + return ObservablesArray(self._array.reshape(shape), copy=False, validate=False) + + def ravel(self) -> ObservablesArray: + """Return a new array with one dimension. + + The returned array has a :attr:`shape` given by ``(size, )``, where + the size is the :attr:`~size` of this array. + + Returns: + A new flattened array. + """ + return self.reshape(self.size) + + @classmethod + def format_observable(cls, observable: BasisObservableLike) -> BasisObservable: + """Format an observable-like object into a :const:`BasisObservable`. + + Args: + observable: The observable-like to format. + + Returns: + The given observable as a :const:`~BasisObservable`. + + Raises: + TypeError: If the input cannot be formatted because its type is not valid. + ValueError: If the input observable is invalid. + """ + + # Pauli-type conversions + if isinstance(observable, SparsePauliOp): + # Call simplify to combine duplicate keys before converting to a mapping + return cls.format_observable(dict(observable.simplify(atol=0).to_list())) + + if isinstance(observable, Pauli): + label, phase = observable[:].to_label(), observable.phase + return {label: 1} if phase == 0 else {label: (-1j) ** phase} + + # String conversion + if isinstance(observable, str): + cls._validate_basis(observable) + return {observable: 1} + + # Mapping conversion (with possible Pauli keys) + if isinstance(observable, MappingType): + num_qubits = len(next(iter(observable))) + unique = defaultdict(complex) + for basis, coeff in observable.items(): + if isinstance(basis, Pauli): + basis, phase = basis[:].to_label(), basis.phase + if phase != 0: + coeff = coeff * (-1j) ** phase + # Validate basis + cls._validate_basis(basis) + if len(basis) != num_qubits: + raise ValueError( + "Number of qubits must be the same for all observable basis elements." + ) + unique[basis] += coeff + return dict(unique) + + raise TypeError(f"Invalid observable type: {type(observable)}") + + @classmethod + def coerce(cls, observables: ObservablesArrayLike) -> ObservablesArray: + """Coerce ObservablesArrayLike into ObservableArray. + + Args: + observables: an object to be observables array. + + Returns: + A coerced observables array. + """ + if isinstance(observables, ObservablesArray): + return observables + if isinstance(observables, (str, SparsePauliOp, Pauli, Mapping)): + observables = [observables] + return cls(observables) + + def validate(self): + """Validate the consistency in observables array.""" + pass + + @classmethod + def _validate_basis(cls, basis: str) -> None: + """Validate a basis string. + + Args: + basis: a basis string to validate. + + Raises: + ValueError: If basis string contains invalid characters + """ + # NOTE: the allowed basis characters can be overridden by modifying the class + # attribute ALLOWED_BASIS + allowed_pattern = _regex_match(cls.ALLOWED_BASIS) + if not allowed_pattern.match(basis): + invalid_pattern = _regex_invalid(cls.ALLOWED_BASIS) + invalid_chars = list(set(invalid_pattern.findall(basis))) + raise ValueError( + f"Observable basis string '{basis}' contains invalid characters {invalid_chars}," + f" allowed characters are {list(cls.ALLOWED_BASIS)}.", + ) + + +ObservablesArrayLike = Union[ObservablesArray, ArrayLike, BasisObservableLike] +"""Types that can be natively converted to an ObservablesArray""" + + +class PauliArray(ObservablesArray): + """An ND-array of Pauli-basis observables for an :class:`.Estimator` primitive.""" + + ALLOWED_BASIS = "IXYZ" + + +@lru_cache(1) +def _regex_match(allowed_chars: str) -> re.Pattern: + """Return pattern for matching if a string contains only the allowed characters.""" + return re.compile(f"^[{re.escape(allowed_chars)}]*$") + + +@lru_cache(1) +def _regex_invalid(allowed_chars: str) -> re.Pattern: + """Return pattern for selecting invalid strings""" + return re.compile(f"[^{re.escape(allowed_chars)}]") diff --git a/qiskit_ibm_runtime/qiskit/primitives/options.py b/qiskit_ibm_runtime/qiskit/primitives/options.py new file mode 100644 index 000000000..5d421b81d --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/options.py @@ -0,0 +1,40 @@ +# 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. + +""" +Options class +""" + +from __future__ import annotations + +from abc import ABC +from typing import Union, Any + +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass + +primitive_dataclass = dataclass( + config=ConfigDict(validate_assignment=True, arbitrary_types_allowed=True, extra="forbid") +) + + +@primitive_dataclass +class BasePrimitiveOptions(ABC): + """Base calss of options for primitives.""" + + def update(self, **kwargs: Any) -> None: + """Update the options.""" + for key, val in kwargs.items(): + setattr(self, key, val) + + +BasePrimitiveOptionsLike = Union[BasePrimitiveOptions, dict] diff --git a/qiskit_ibm_runtime/qiskit/primitives/sampler_task.py b/qiskit_ibm_runtime/qiskit/primitives/sampler_task.py new file mode 100644 index 000000000..bcc361227 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/sampler_task.py @@ -0,0 +1,82 @@ +# 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. +# type: ignore + +""" +Sampler Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Union, Optional, Tuple + +from qiskit import QuantumCircuit + +from .base_task import BaseTask +from .bindings_array import BindingsArray, BindingsArrayLike +from .shape import ShapedMixin + + +@dataclass(frozen=True) +class SamplerTask(BaseTask, ShapedMixin): + """Task for Sampler. + Task is composed of triple (circuit, parameter_values). + """ + + parameter_values: Optional[BindingsArray] = BindingsArray([], shape=()) + _shape: tuple[int, ...] = field(init=False) + + def __post_init__(self): + super().__setattr__("_shape", self.parameter_values.shape) + + @classmethod + def coerce(cls, task: SamplerTaskLike) -> SamplerTask: + """Coerce SamplerTaskLike into SamplerTask. + + Args: + task: an object to be sampler task. + + Returns: + A coerced estiamtor task. + + Raises: + ValueError: If input values are invalid. + """ + if isinstance(task, SamplerTask): + return task + if len(task) != 1 and len(task) != 2: + raise ValueError(f"The length of task must be 1 or 2, but length {len(task)} is given.") + circuit = task[0] + parameter_values = ( + BindingsArray.coerce(task[1]) if len(task) == 2 else BindingsArray([], shape=()) + ) + return cls(circuit=circuit, parameter_values=parameter_values) + + def validate(self) -> None: + """Validate the task. + + Raises: + ValueError: If input values are invalid. + """ + super().validate() + self.parameter_values.validate() + # Cross validate circuits and paramter_values + num_parameters = self.parameter_values.num_parameters + if num_parameters != self.circuit.num_parameters: + raise ValueError( + f"The number of values ({num_parameters}) does not match " + f"the number of parameters ({self.circuit.num_parameters}) for the circuit." + ) + + +SamplerTaskLike = Union[SamplerTask, Tuple[QuantumCircuit, BindingsArrayLike]] diff --git a/qiskit_ibm_runtime/qiskit/primitives/shape.py b/qiskit_ibm_runtime/qiskit/primitives/shape.py new file mode 100644 index 000000000..6134ef372 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/shape.py @@ -0,0 +1,130 @@ +# 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. +# type: ignore + +""" +Array shape related classes and functions +""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Protocol, Tuple, Union, runtime_checkable + +import numpy as np +from numpy.typing import ArrayLike, NDArray + +ShapeInput = Union[int, "Iterable[ShapeInput]"] +"""An input that is coercible into a shape tuple.""" + + +@runtime_checkable +class Shaped(Protocol): + """Protocol that defines what it means to be a shaped object. + + Note that static type checkers will classify ``numpy.ndarray`` as being :class:`Shaped`. + Moreover, since this protocol is runtime-checkable, we will even have + ``isinstance(, Shaped) == True``. + """ + + @property + def shape(self) -> Tuple[int, ...]: + """The array shape of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `shape` property") + + @property + def ndim(self) -> int: + """The number of array dimensions of this object.""" + raise NotImplementedError("A `Shaped` protocol must implement the `ndim` property") + + @property + def size(self) -> int: + """The total dimension of this object, i.e. the product of the entries of :attr:`~shape`.""" + raise NotImplementedError("A `Shaped` protocol must implement the `size` property") + + +class ShapedMixin(Shaped): + """Mixin class to create :class:`~Shaped` types by only providing :attr:`_shape` attribute.""" + + _shape: Tuple[int, ...] + + def __repr__(self): + return f"{type(self).__name__}(<{self.shape}>)" + + @property + def shape(self): + return self._shape + + @property + def ndim(self): + return len(self._shape) + + @property + def size(self): + return int(np.prod(self._shape, dtype=int)) + + +def array_coerce(arr: Union[ArrayLike, Shaped]) -> Union[NDArray, Shaped]: + """Coerce the input into an object with a shape attribute. + + Copies are avoided. + + Args: + arr: The object to coerce. + + Returns: + Something that is :class:`~Shaped`, and always ``numpy.ndarray`` if the input is not + already :class:`~Shaped`. + """ + if isinstance(arr, Shaped): + return arr + return np.array(arr, copy=False) + + +def _flatten_to_ints(arg: ShapeInput) -> Iterable[int]: + """ + Yield one integer at a time. + + Args: + arg: Integers or iterables of integers, possibly nested, to be yielded. + + Yields: + The provided integers in depth-first recursive order. + + Raises: + ValueError: If an input is not an iterable or an integer. + """ + for item in arg: + try: + if isinstance(item, Iterable): + yield from _flatten_to_ints(item) + elif int(item) == item: + yield int(item) + else: + raise ValueError(f"Expected {item} to be iterable or an integer.") + except (TypeError, RecursionError) as ex: + raise ValueError(f"Expected {item} to be iterable or an integer.") from ex + + +def shape_tuple(*shapes: ShapeInput) -> Tuple[int, ...]: # pylint: disable=differing-param-doc + """ + Flatten the input into a single tuple of integers, preserving order. + + Args: + shapes: Integers or iterables of integers, possibly nested. + + Returns: + A tuple of integers. + + Raises: + ValueError: If some member of ``shapes`` is not an integer or iterable. + """ + return tuple(_flatten_to_ints(shapes)) diff --git a/qiskit_ibm_runtime/qiskit/primitives/task_result.py b/qiskit_ibm_runtime/qiskit/primitives/task_result.py new file mode 100644 index 000000000..98ee15be7 --- /dev/null +++ b/qiskit_ibm_runtime/qiskit/primitives/task_result.py @@ -0,0 +1,27 @@ +# 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. + +""" +Base Task class +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TaskResult: + """Result of task.""" + + data: dict + metadata: dict diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index 6ef274fad..5564f87e0 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -1067,6 +1067,7 @@ def run( f"The backend {backend.name} currently has a status of {status.status_msg}." ) + version = inputs.get("version", 1) if inputs else 1 try: response = self._api_client.program_run( program_id=program_id, @@ -1110,6 +1111,7 @@ def run( result_decoder=result_decoder, image=qrt_options.image, service=self, + version=version, ) return job diff --git a/qiskit_ibm_runtime/runtime_job.py b/qiskit_ibm_runtime/runtime_job.py index 80b77f3f5..98fe1dc52 100644 --- a/qiskit_ibm_runtime/runtime_job.py +++ b/qiskit_ibm_runtime/runtime_job.py @@ -31,6 +31,7 @@ from qiskit_ibm_runtime import qiskit_runtime_service from .utils.utils import validate_job_tags +from .utils.estimator_result_decoder import EstimatorResultDecoder from .constants import API_TO_JOB_ERROR_MESSAGE, API_TO_JOB_STATUS, DEFAULT_DECODERS from .exceptions import ( IBMApiError, @@ -102,6 +103,7 @@ def __init__( image: Optional[str] = "", session_id: Optional[str] = None, tags: Optional[List] = None, + version: Optional[int] = None, ) -> None: """RuntimeJob constructor. @@ -119,6 +121,7 @@ def __init__( service: Runtime service. session_id: Job ID of the first job in a runtime session. tags: Tags assigned to the job. + version: Primitive version. """ super().__init__(backend=backend, job_id=job_id) self._api_client = api_client @@ -135,6 +138,7 @@ def __init__( self._session_id = session_id self._tags = tags self._usage_estimation: Dict[str, Any] = {} + self._version = version decoder = result_decoder or DEFAULT_DECODERS.get(program_id, None) or ResultDecoder if isinstance(decoder, Sequence): @@ -226,7 +230,14 @@ def result( # pylint: disable=arguments-differ self._api_client.job_results(job_id=self.job_id()) ) - return _decoder.decode(result_raw) if result_raw else None + version_param = {} + # TODO: Remove getting/setting version once it's in result metadata + if _decoder.__name__ == EstimatorResultDecoder.__name__: + if not self._version: + self._version = self.inputs.get("version", 1) + version_param["version"] = self._version + + return _decoder.decode(result_raw, **version_param) if result_raw else None # type: ignore def cancel(self) -> None: """Cancel the job. diff --git a/qiskit_ibm_runtime/sampler.py b/qiskit_ibm_runtime/sampler.py index bddde67fc..e165bb8ff 100644 --- a/qiskit_ibm_runtime/sampler.py +++ b/qiskit_ibm_runtime/sampler.py @@ -37,7 +37,7 @@ class Sampler: - """Base type for Sampelr.""" + """Base type for Sampler.""" version = 0 @@ -45,34 +45,17 @@ class Sampler: class SamplerV2(BasePrimitiveV2, Sampler, BaseSamplerV2): """Class for interacting with Qiskit Runtime Sampler primitive service. - Qiskit Runtime Sampler primitive service calculates quasi-probability distribution - of bitstrings from quantum circuits. - - The :meth:`run` method can be used to submit circuits and parameters to the Sampler primitive. - - You are encouraged to use :class:`~qiskit_ibm_runtime.Session` to open a session, - during which you can invoke one or more primitives. Jobs submitted within a session - are prioritized by the scheduler, and data is cached for efficiency. - - Example:: + This class supports version 2 of the Sampler interface, which uses different + input and output formats than version 1. - from qiskit.test.reference_circuits import ReferenceCircuits - from qiskit_ibm_runtime import QiskitRuntimeService, Session, Sampler - - service = QiskitRuntimeService(channel="ibm_cloud") - bell = ReferenceCircuits.bell() - - with Session(service, backend="ibmq_qasm_simulator") as session: - sampler = Sampler(session=session) - - job = sampler.run(bell, shots=1024) - print(f"Job ID: {job.job_id()}") - print(f"Job result: {job.result()}") + Qiskit Runtime Sampler primitive returns the sampled result according to the + specified output type. For example, it returns a bitstring for each shot + if measurement level 2 (bits) is requested. - # You can run more jobs inside the session + The :meth:`run` method can be used to submit circuits and parameters to the Sampler primitive. """ - _OPTIONS_CLASS = SamplerOptions + _options_class = SamplerOptions version = 2 @@ -107,77 +90,10 @@ def __init__( Sampler.__init__(self) BasePrimitiveV2.__init__(self, backend=backend, session=session, options=options) - if self._service._channel_strategy == "q-ctrl": - raise NotImplementedError("SamplerV2 is not supported with q-ctrl channel strategy.") - - def run( # pylint: disable=arguments-differ - self, - circuits: QuantumCircuit | Sequence[QuantumCircuit], - parameter_values: Sequence[float] | Sequence[Sequence[float]] | None = None, - **kwargs: Any, - ) -> RuntimeJob: - """Submit a request to the estimator primitive. - - Args: - circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or - a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. - - parameter_values: Concrete parameters to be bound. - - **kwargs: Individual options to overwrite the default primitive options. - These include the runtime options in :class:`qiskit_ibm_runtime.RuntimeOptions`. - - Returns: - Submitted job. - The result of the job is an instance of :class:`qiskit.primitives.EstimatorResult`. - - Raises: - ValueError: Invalid arguments are given. - """ - # To bypass base class merging of options. - user_kwargs = {"_user_kwargs": kwargs} - - return super().run( - circuits=circuits, - parameter_values=parameter_values, - **user_kwargs, - ) - - def _run( # pylint: disable=arguments-differ - self, - circuits: Sequence[QuantumCircuit], - parameter_values: tuple[tuple[float, ...], ...], - **kwargs: Any, - ) -> RuntimeJob: - """Submit a request to the estimator primitive. - - Args: - circuits: a (parameterized) :class:`~qiskit.circuit.QuantumCircuit` or - a list of (parameterized) :class:`~qiskit.circuit.QuantumCircuit`. - - observables: A list of observable objects. + raise NotImplementedError("SamplerV2 is not currently supported.") - parameter_values: An optional list of concrete parameters to be bound. - - **kwargs: Individual options to overwrite the default primitive options. - These include the runtime options in :class:`~qiskit_ibm_runtime.RuntimeOptions`. - - Returns: - Submitted job - """ - logger.debug( - "Running %s with new options %s", - self.__class__.__name__, - kwargs.get("_user_kwargs", {}), - ) - inputs = { - "circuits": circuits, - "parameters": [circ.parameters for circ in circuits], - "parameter_values": parameter_values, - } - return self._run_primitive( - primitive_inputs=inputs, user_kwargs=kwargs.get("_user_kwargs", {}) - ) + # if self._service._channel_strategy == "q-ctrl": + # raise NotImplementedError("SamplerV2 is not supported with q-ctrl channel strategy.") def _validate_options(self, options: dict) -> None: """Validate that program inputs (options) are valid @@ -185,7 +101,7 @@ def _validate_options(self, options: dict) -> None: Raises: ValidationError: if validation fails. """ - self._OPTIONS_CLASS(**options) + pass @classmethod def _program_id(cls) -> str: @@ -223,7 +139,7 @@ class SamplerV1(BasePrimitiveV1, Sampler, BaseSampler): # You can run more jobs inside the session """ - _OPTIONS_CLASS = Options + _options_class = Options def __init__( self, diff --git a/qiskit_ibm_runtime/utils/estimator_result_decoder.py b/qiskit_ibm_runtime/utils/estimator_result_decoder.py index b4423d14c..bbb82d3e2 100644 --- a/qiskit_ibm_runtime/utils/estimator_result_decoder.py +++ b/qiskit_ibm_runtime/utils/estimator_result_decoder.py @@ -18,15 +18,27 @@ from qiskit.primitives import EstimatorResult from ..program.result_decoder import ResultDecoder +from ..qiskit.primitives.task_result import TaskResult class EstimatorResultDecoder(ResultDecoder): """Class used to decode estimator results""" @classmethod - def decode(cls, raw_result: str) -> EstimatorResult: + def decode( # type: ignore # pylint: disable=arguments-differ + cls, raw_result: str, version: int + ) -> EstimatorResult: """Convert the result to EstimatorResult.""" decoded: Dict = super().decode(raw_result) + if version == 2: + out_results = [] + for val, meta in zip(decoded["values"], decoded["metadata"]): + if not isinstance(val, np.ndarray): + val = np.asarray(val) + out_results.append( + TaskResult(data={"evs": val, "stds": meta.pop("standard_error")}, metadata=meta) + ) + return out_results return EstimatorResult( values=np.asarray(decoded["values"]), metadata=decoded["metadata"], diff --git a/qiskit_ibm_runtime/utils/json.py b/qiskit_ibm_runtime/utils/json.py index de477f982..32681cb25 100644 --- a/qiskit_ibm_runtime/utils/json.py +++ b/qiskit_ibm_runtime/utils/json.py @@ -26,6 +26,7 @@ import zlib from datetime import date from typing import Any, Callable, Dict, List, Union, Tuple +from dataclasses import asdict import dateutil.parser import numpy as np @@ -66,6 +67,10 @@ load, ) +# TODO: Remove when they are in terra +from ..qiskit.primitives import ObservablesArray, BindingsArray +from ..qiskit.primitives.base_task import BaseTask + _TERRA_VERSION = tuple( int(x) for x in re.match(r"\d+\.\d+\.\d", _terra_version_string).group(0).split(".")[:3] ) @@ -249,6 +254,22 @@ def default(self, obj: Any) -> Any: # pylint: disable=arguments-differ serializer=lambda buff, data: dump(data, buff), # type: ignore[no-untyped-call] ) return {"__type__": "Instruction", "__value__": value} + if isinstance(obj, BaseTask): + return asdict(obj) + if isinstance(obj, ObservablesArray): + return obj.tolist() + if isinstance(obj, BindingsArray): + out_val = {} + if obj.kwvals: + encoded_kwvals = {} + for key, val in obj.kwvals.items(): + encoded_kwvals[json.dumps(key, cls=RuntimeEncoder)] = val + out_val["kwvals"] = encoded_kwvals + if obj.vals: + out_val["vals"] = obj.vals # type: ignore[assignment] + out_val["shape"] = obj.shape + return {"__type__": "BindingsArray", "__value__": out_val} + if HAS_AER and isinstance(obj, qiskit_aer.noise.NoiseModel): return {"__type__": "NoiseModel", "__value__": obj.to_dict()} if hasattr(obj, "settings"): @@ -321,6 +342,22 @@ def object_hook(self, obj: Any) -> Any: return Result.from_dict(obj_val) if obj_type == "spmatrix": return _decode_and_deserialize(obj_val, scipy.sparse.load_npz, False) + if obj_type == "BindingsArray": + ba_kwargs = {"shape": obj_val.get("shape", None)} + kwvals = obj_val.get("kwvals", None) + if isinstance(kwvals, dict): + kwvals_decoded = {} + for key, val in kwvals.items(): + # Convert to tuple or it can't be a key + decoded_key = tuple(json.loads(key, cls=RuntimeDecoder)) + kwvals_decoded[decoded_key] = val + ba_kwargs["kwvals"] = kwvals_decoded + elif kwvals: + raise ValueError(f"Unexpected kwvals type {type(kwvals)} in BindingsArray.") + ba_kwargs["vals"] = obj_val.get("vals", None) + + return BindingsArray(**ba_kwargs) + if obj_type == "to_json": return obj_val if obj_type == "NoiseModel": diff --git a/test/unit/test_data_serialization.py b/test/unit/test_data_serialization.py index 77dea3833..c6c4bb189 100644 --- a/test/unit/test_data_serialization.py +++ b/test/unit/test_data_serialization.py @@ -21,15 +21,21 @@ from datetime import datetime import numpy as np +from ddt import data, ddt from qiskit.circuit import Parameter, QuantumCircuit from qiskit.test.reference_circuits import ReferenceCircuits from qiskit.circuit.library import EfficientSU2, CXGate, PhaseGate, U2Gate from qiskit.providers.fake_provider import FakeNairobi +import qiskit.quantum_info as qi from qiskit.quantum_info import SparsePauliOp, Pauli, Statevector from qiskit.result import Result from qiskit_aer.noise import NoiseModel from qiskit_ibm_runtime.utils import RuntimeEncoder, RuntimeDecoder + +# TODO: Remove when they are in terra +from qiskit_ibm_runtime.qiskit.primitives import BindingsArray, ObservablesArray + from .mock.fake_runtime_client import CustomResultRuntimeJob from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase @@ -42,6 +48,7 @@ from ..utils import mock_wait_for_final_state +@ddt class TestDataSerialization(IBMTestCase): """Class for testing runtime data serialization.""" @@ -56,7 +63,7 @@ def test_coder(self): results=[], ) - data = { + base_types = { "string": "foo", "float": 1.5, "complex": 2 + 3j, @@ -64,17 +71,17 @@ def test_coder(self): "result": result, "sclass": SerializableClass("foo"), } - encoded = json.dumps(data, cls=RuntimeEncoder) + encoded = json.dumps(base_types, cls=RuntimeEncoder) decoded = json.loads(encoded, cls=RuntimeDecoder) decoded["sclass"] = SerializableClass.from_json(decoded["sclass"]) decoded_result = decoded.pop("result") - data.pop("result") + base_types.pop("result") decoded_array = decoded.pop("array") - orig_array = data.pop("array") + orig_array = base_types.pop("array") - self.assertEqual(decoded, data) + self.assertEqual(decoded, base_types) self.assertIsInstance(decoded_result, Result) self.assertTrue((decoded_array == orig_array).all()) @@ -252,3 +259,69 @@ def test_circuit_metadata(self): payload = {"circuits": [circ]} self.assertTrue(json.dumps(payload, cls=RuntimeEncoder)) + + def test_encoder_tasks(self): + """Test serializing tasks.""" + pass + + @data( + ObservablesArray([["X", "Y", "Z"], ["0", "1", "+"]]), + ObservablesArray(qi.pauli_basis(2)), + ObservablesArray([qi.random_pauli_list(2, 3) for _ in range(5)]), + ObservablesArray(np.array([["X", "Y"], ["Z", "I"]], dtype=object)), + ObservablesArray( + [[SparsePauliOp(qi.random_pauli_list(2, 3)) for _ in range(3)] for _ in range(5)] + ), + ) + def test_obs_array(self, oarray): + """Test encoding and decoding ObservablesArray""" + payload = {"array": oarray} + encoded = json.dumps(payload, cls=RuntimeEncoder) + decoded = json.loads(encoded, cls=RuntimeDecoder)["array"] + self.assertIsInstance(decoded, list) + self.assertEqual(decoded, oarray.tolist()) + + @data( + BindingsArray([1, 2, 3.4]), + BindingsArray([4.0, 5.0, 6.0], shape=()), + BindingsArray([[1 + 2j, 2 + 3j], [3 + 4j, 4 + 5j]], shape=(2,)), + BindingsArray(np.random.uniform(size=(5,))), + BindingsArray(np.linspace(0, 1, 30).reshape((2, 3, 5))), + BindingsArray(kwvals={Parameter("a"): [0.0], Parameter("b"): [1.0]}, shape=1), + BindingsArray( + kwvals={ + (Parameter("a"), Parameter("b")): np.random.random((4, 3, 2)), + Parameter("c"): np.random.random((4, 3)), + } + ), + BindingsArray( + vals=np.random.random((2, 3, 4)), + kwvals={ + (Parameter("a"), Parameter("b")): np.random.random((2, 3, 2)), + Parameter("c"): np.random.random((2, 3)), + }, + ), + BindingsArray(vals=[[1.0, 2.0], [1.1, 2.1]], kwvals={Parameter("c"): [3.0, 3.1]}), + ) + def test_bindings_array(self, barray): + """Test encoding and decoding BindingsArray.""" + + def _to_str_keyed(_in_dict): + _out_dict = {} + for a_key_tuple, val in _in_dict.items(): + str_key = tuple(a_key.name for a_key in a_key_tuple) + _out_dict[str_key] = val + return _out_dict + + payload = {"array": barray} + encoded = json.dumps(payload, cls=RuntimeEncoder) + decoded = json.loads(encoded, cls=RuntimeDecoder)["array"] + self.assertIsInstance(decoded, BindingsArray) + self.assertEqual(barray.shape, decoded.shape) + self.assertTrue(np.allclose(barray.vals, decoded.vals)) + if barray.kwvals: + barray_str_keyed = _to_str_keyed(barray.kwvals) + decoded_str_keyed = _to_str_keyed(decoded.kwvals) + for key, val in barray_str_keyed.items(): + self.assertIn(key, decoded_str_keyed) + self.assertTrue(np.allclose(val, decoded_str_keyed[key])) diff --git a/test/unit/test_estimator.py b/test/unit/test_estimator.py index 9d8b30cf4..4472c3934 100644 --- a/test/unit/test_estimator.py +++ b/test/unit/test_estimator.py @@ -15,13 +15,15 @@ from unittest.mock import MagicMock from qiskit import QuantumCircuit -from qiskit.quantum_info import SparsePauliOp, Pauli -from qiskit.circuit import Parameter +from qiskit.circuit.library import RealAmplitudes +from qiskit.quantum_info import SparsePauliOp, Pauli, random_pauli_list +import qiskit.quantum_info as qi import numpy as np from ddt import data, ddt from qiskit_ibm_runtime import Estimator, Session, EstimatorV2, EstimatorOptions +from qiskit_ibm_runtime.qiskit.primitives import EstimatorTask from .mock.fake_runtime_service import FakeRuntimeService from ..ibm_test_case import IBMTestCase @@ -63,6 +65,32 @@ def setUp(self) -> None: self.circuit = QuantumCircuit(1, 1) self.observables = SparsePauliOp.from_list([("I", 1)]) + @data( + [(RealAmplitudes(num_qubits=2, reps=1), ["ZZ"], [1, 2, 3, 4])], + [(RealAmplitudes(num_qubits=2, reps=1), ["ZZ", "YY"], [1, 2, 3, 4])], + [(QuantumCircuit(2), ["XX"])], + [(RealAmplitudes(num_qubits=1, reps=1), ["I"], [1, 2]), (QuantumCircuit(3), ["YYY"])], + ) + def test_run_program_inputs(self, in_tasks): + """Verify program inputs are correct.""" + session = MagicMock(spec=MockSession) + inst = EstimatorV2(session=session) + inst.run(in_tasks) + input_params = session.run.call_args.kwargs["inputs"] + self.assertIn("tasks", input_params) + tasks_param = input_params["tasks"] + for a_task_param, an_in_taks in zip(tasks_param, in_tasks): + self.assertIsInstance(a_task_param, EstimatorTask) + # Check circuit + self.assertEqual(a_task_param.circuit, an_in_taks[0]) + # Check observables + a_task_obs = a_task_param.observables.tolist() + for a_task_obs, an_input_obs in zip(a_task_param.observables.tolist(), an_in_taks[1]): + self.assertEqual(list(a_task_obs.keys())[0], an_input_obs) + # Check parameter values + an_input_params = an_in_taks[2] if len(an_in_taks) == 3 else [] + np.allclose(a_task_param.parameter_values.vals, an_input_params) + def test_unsupported_values_for_estimator_options(self): """Test exception when options levels are not supported.""" options_bad = [ @@ -77,7 +105,7 @@ def test_unsupported_values_for_estimator_options(self): for bad_opt in options_bad: inst = EstimatorV2(session=session) with self.assertRaises(ValueError) as exc: - _ = inst.run(self.circuit, observables=self.observables, **bad_opt) + inst.options.update(**bad_opt) self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) def test_res_level3_simulator(self): @@ -88,16 +116,19 @@ def test_res_level3_simulator(self): inst = EstimatorV2(session=session, options={"resilience_level": 3}) with self.assertRaises(ValueError) as exc: - inst.run(self.circuit, observables=self.observables) + inst.run((self.circuit, self.observables)) self.assertIn("coupling map", str(exc.exception)) def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) options_vars = [ - (EstimatorOptions(resilience_level=1), {"resilience_level": 1}), ( - EstimatorOptions(optimization_level=3), + EstimatorOptions(resilience_level=1), # pylint: disable=unexpected-keyword-arg + {"resilience_level": 1}, + ), + ( + EstimatorOptions(optimization_level=3), # pylint: disable=unexpected-keyword-arg {"transpilation": {"optimization_level": 3}}, ), ( @@ -114,7 +145,7 @@ def test_run_default_options(self): for options, expected in options_vars: with self.subTest(options=options): inst = EstimatorV2(session=session, options=options) - inst.run(self.circuit, observables=self.observables) + inst.run((self.circuit, self.observables)) inputs = session.run.call_args.kwargs["inputs"] self.assertTrue( dict_paritally_equal(inputs, expected), @@ -130,74 +161,87 @@ def test_invalid_resilience_options(self, res_opt): session = MagicMock(spec=MockSession) with self.assertRaises(ValueError) as exc: inst = EstimatorV2(session=session, options={"resilience": res_opt}) - inst.run(self.circuit, observables=self.observables) + inst.run((self.circuit, self.observables)) self.assertIn(list(res_opt.values())[0], str(exc.exception)) if len(res_opt.keys()) > 1: self.assertIn(list(res_opt.keys())[1], str(exc.exception)) - def test_observable_types_single_circuit(self): + @data(True, False) + def test_observable_types_single_circuit(self, to_task): """Test different observable types for a single circuit.""" all_obs = [ - "IX", - Pauli("YZ"), - SparsePauliOp(["IX", "YZ"]), - {"YZ": 1 + 2j}, - {Pauli("XX"): 1 + 2j}, - [["XX", "YY"]], - [[Pauli("XX"), Pauli("YY")]], - [[SparsePauliOp(["XX"]), SparsePauliOp(["YY"])]], + # TODO: Uncomment single ObservableArrayLike when supported + # "IX", + # Pauli("YZ"), + # SparsePauliOp(["IX", "YZ"]), + # {"YZ": 1 + 2j}, + # {Pauli("XX"): 1 + 2j}, + ["XX", "YY"], + [qi.random_pauli_list(2)], + [Pauli("XX"), Pauli("YY")], + [SparsePauliOp(["XX"]), SparsePauliOp(["YY"])], [ - [ - {"XX": 1 + 2j}, - {"YY": 1 + 2j}, - ] + {"XX": 1 + 2j}, + {"YY": 1 + 2j}, ], [ - [ - {Pauli("XX"): 1 + 2j}, - {Pauli("YY"): 1 + 2j}, - ] + {Pauli("XX"): 1 + 2j}, + {Pauli("YY"): 1 + 2j}, ], + [random_pauli_list(2, 2)], + [random_pauli_list(2, 3) for _ in range(5)], + np.array([["II", "XX", "YY"], ["ZZ", "XZ", "II"]], dtype=object), ] circuit = QuantumCircuit(2) estimator = EstimatorV2(backend=get_mocked_backend()) for obs in all_obs: with self.subTest(obs=obs): - estimator.run(circuits=circuit, observables=obs) + task = (circuit, obs) + if to_task: + task = EstimatorTask.coerce(task) + estimator.run(task) 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"])], + # TODO: Uncomment single ObservableArrayLike when supported + # ["XX", "YYY"], + # [Pauli("XX"), Pauli("YYY")], + # [SparsePauliOp(["XX"]), SparsePauliOp(["YYY"])], + # [ + # {"XX": 1 + 2j}, + # {"YYY": 1 + 2j}, + # ], + # [ + # {Pauli("XX"): 1 + 2j}, + # {Pauli("YYY"): 1 + 2j}, + # ], + [["XX", "YY"], ["ZZZ", "III"]], + [[Pauli("XX"), Pauli("YY")], [Pauli("XXX"), Pauli("YYY")]], [ - {"XX": 1 + 2j}, - {"YY": 1 + 2j}, + [SparsePauliOp(["XX"]), SparsePauliOp(["YY"])], + [SparsePauliOp(["XXX"]), SparsePauliOp(["YYY"])], ], + [[{"XX": 1 + 2j}, {"YY": 1 + 2j}], [{"XXX": 1 + 2j}, {"YYY": 1 + 2j}]], [ - {Pauli("XX"): 1 + 2j}, - {Pauli("YY"): 1 + 2j}, + [{Pauli("XX"): 1 + 2j}, {Pauli("YY"): 1 + 2j}], + [{Pauli("XXX"): 1 + 2j}, {Pauli("YYY"): 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), random_pauli_list(3, 2)], ] - circuit = QuantumCircuit(2) + circuit1 = QuantumCircuit(2) + circuit2 = QuantumCircuit(3) estimator = EstimatorV2(backend=get_mocked_backend()) for obs in all_obs: with self.subTest(obs=obs): - estimator.run(circuits=[circuit] * num_qx, observables=obs) + estimator.run(tasks=[(circuit1, obs[0]), (circuit2, obs[1])]) def test_invalid_basis(self): """Test observable containing invalid basis.""" all_obs = [ - "JJ", + ["JJ"], {"JJ": 1 + 2j}, [["0J", "YY"]], [ @@ -213,66 +257,4 @@ def test_invalid_basis(self): 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 = EstimatorV2(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 = EstimatorV2(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 = EstimatorV2(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) + estimator.run((circuit, obs)) diff --git a/test/unit/test_ibm_primitives_v2.py b/test/unit/test_ibm_primitives_v2.py index 6a1bb8685..5c696f77a 100644 --- a/test/unit/test_ibm_primitives_v2.py +++ b/test/unit/test_ibm_primitives_v2.py @@ -18,8 +18,11 @@ from unittest.mock import MagicMock, patch from ddt import data, ddt +import numpy as np + from qiskit import transpile from qiskit.circuit import QuantumCircuit +from qiskit.circuit.library import RealAmplitudes from qiskit.test.reference_circuits import ReferenceCircuits from qiskit.quantum_info import SparsePauliOp from qiskit.providers.fake_provider import FakeManila @@ -32,8 +35,9 @@ ) from qiskit_ibm_runtime.ibm_backend import IBMBackend from qiskit_ibm_runtime.utils.default_session import _DEFAULT_SESSION -from qiskit_ibm_runtime import EstimatorV2, SamplerV2 +from qiskit_ibm_runtime import EstimatorV2 from qiskit_ibm_runtime.estimator import Estimator as IBMBaseEstimator +from qiskit_ibm_runtime.qiskit.primitives import BindingsArray from ..ibm_test_case import IBMTestCase from ..utils import ( @@ -44,16 +48,18 @@ combine, MockSession, get_primitive_inputs, + get_mocked_backend, ) +# TODO: Add SamplerV2 back when it's supported @ddt class TestPrimitivesV2(IBMTestCase): """Class for testing the Sampler and Estimator classes.""" @classmethod def setUpClass(cls): - cls.qx = ReferenceCircuits.bell() + cls.circ = ReferenceCircuits.bell() cls.obs = SparsePauliOp.from_list([("IZ", 1)]) return super().setUpClass() @@ -61,7 +67,7 @@ def tearDown(self) -> None: super().tearDown() _DEFAULT_SESSION.set(None) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_dict_options(self, primitive): """Test passing a dictionary as options.""" options_vars = [ @@ -79,7 +85,7 @@ def test_dict_options(self, primitive): self.assertTrue(dict_paritally_equal(asdict(inst.options), options)) @combine( - primitive=[EstimatorV2, SamplerV2], + primitive=[EstimatorV2], env_var=[ {"log_level": "DEBUG"}, {"job_tags": ["foo", "bar"]}, @@ -88,7 +94,7 @@ def test_dict_options(self, primitive): def test_runtime_options(self, primitive, env_var): """Test RuntimeOptions specified as primitive options.""" session = MagicMock(spec=MockSession) - options = primitive._OPTIONS_CLASS(environment=env_var) + options = primitive._options_class(environment=env_var) inst = primitive(session=session, options=options) inst.run(**get_primitive_inputs(inst)) run_options = session.run.call_args.kwargs["options"] @@ -105,9 +111,9 @@ def test_runtime_options(self, primitive, env_var): def test_image(self, primitive, opts): """Test passing an image to options.""" session = MagicMock(spec=MockSession) - options = primitive._OPTIONS_CLASS(**opts) + options = primitive._options_class(**opts) inst = primitive(session=session, options=options) - inst.run(self.qx, observables=self.obs) + inst.run(**get_primitive_inputs(inst)) run_options = session.run.call_args.kwargs["options"] input_params = session.run.call_args.kwargs["inputs"] expected = list(opts.values())[0] @@ -118,13 +124,13 @@ def test_image(self, primitive, opts): @data(EstimatorV2) def test_options_copied(self, primitive): """Test modifying original options does not affect primitives.""" - options = primitive._OPTIONS_CLASS() + options = primitive._options_class() options.max_execution_time = 100 inst = primitive(session=MagicMock(spec=MockSession), options=options) options.max_execution_time = 200 self.assertEqual(inst.options.max_execution_time, 100) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_backend_str(self, primitive): """Test initializing a primitive with a backend name.""" backend_name = "ibm_gotham" @@ -146,7 +152,7 @@ def test_init_with_backend_str(self, primitive): runtime_options = mock_service_inst.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend_name) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_session_backend_str(self, primitive): """Test initializing a primitive with a backend name using session.""" backend_name = "ibm_gotham" @@ -157,7 +163,7 @@ def test_init_with_session_backend_str(self, primitive): self.assertIsNone(inst.session) self.assertIn("session must be of type Session or None", str(exc.exception)) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_backend_instance(self, primitive): """Test initializing a primitive with a backend instance.""" service = MagicMock() @@ -180,7 +186,7 @@ def test_init_with_backend_instance(self, primitive): self.assertIsNone(inst.session) self.assertIn("session must be of type Session or None", str(exc.exception)) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_backend_session(self, primitive): """Test initializing a primitive with both backend and session.""" session = MagicMock(spec=MockSession) @@ -192,7 +198,7 @@ def test_init_with_backend_session(self, primitive): inst.run(**get_primitive_inputs(inst)) session.run.assert_called_once() - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_no_backend_session_cloud(self, primitive): """Test initializing a primitive without backend or session for cloud channel.""" with patch("qiskit_ibm_runtime.base_primitive.QiskitRuntimeService") as mock_service: @@ -205,7 +211,7 @@ def test_init_with_no_backend_session_cloud(self, primitive): mock_service.assert_called_once() self.assertIsNone(inst.session) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_init_with_no_backend_session_quantum(self, primitive): """Test initializing a primitive without backend or session for quantum channel.""" @@ -214,7 +220,7 @@ def test_init_with_no_backend_session_quantum(self, primitive): with self.assertRaises(ValueError): _ = primitive() - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_default_session_context_manager(self, primitive): """Test getting default session within context manager.""" service = MagicMock() @@ -225,7 +231,7 @@ def test_default_session_context_manager(self, primitive): self.assertEqual(inst.session, session) self.assertEqual(inst.session.backend(), backend) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_default_session_cm_new_backend(self, primitive): """Test using a different backend within context manager.""" cm_backend = "ibm_metropolis" @@ -246,7 +252,7 @@ def test_default_session_cm_new_backend(self, primitive): runtime_options = service.run.call_args.kwargs["options"] self.assertEqual(runtime_options["backend"], backend.name) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_no_session(self, primitive): """Test running without session.""" model_backend = FakeManila() @@ -264,25 +270,93 @@ def test_no_session(self, primitive): self.assertNotIn("session_id", kwargs_list) self.assertNotIn("start_session", kwargs_list) - @data(EstimatorV2, SamplerV2) - def test_run_updated_default_options(self, primitive): - """Test run using updated default options.""" - session = MagicMock(spec=MockSession) - inst = primitive(session=session) - inst.set_options(skip_transpilation=True, optimization_level=2, shots=99) - inst.run(**get_primitive_inputs(inst)) - inputs = session.run.call_args.kwargs["inputs"] - self._assert_dict_partially_equal( - inputs, + @data(EstimatorV2) + def test_parameters_single_circuit(self, primitive): + """Test parameters for a single cirucit.""" + + circ = RealAmplitudes(num_qubits=2, reps=1) + + param_vals = [ + # 1 set of parameter values + [1, 2, 3, 4], + [np.pi] * circ.num_parameters, + np.random.uniform(size=(4,)), + # {param: [2.0] for param in circ.parameters}, # TODO: this doesn't work now + # N sets of parameter values + # [[1, 2, 3, 4]] * 2, # TODO: This doesn't work right now + np.random.random((2, 4)), + np.linspace(0, 1, 24).reshape((2, 3, 4)), + {param: [1, 2, 3] for param in circ.parameters}, + {param: np.linspace(0, 1, 5) for param in circ.parameters}, + {tuple(circ.parameters): np.random.random((2, 3, 4))}, { - "skip_transpilation": True, - "transpilation": {"optimization_level": 2}, - "execution": {"shots": 99}, + tuple(circ.parameters[:2]): np.random.random((2, 1, 2)), + tuple(circ.parameters[2:4]): np.random.random((2, 1, 2)), }, - ) + ] + + inst = primitive(backend=get_mocked_backend()) + for val in param_vals: + with self.subTest(val=val): + task = (circ, "ZZ", val) if isinstance(inst, EstimatorV2) else (circ, val) + inst.run(task) + + @data(EstimatorV2) + def test_parameters_vals_kwvals(self, primitive): + """Test a mixture of vals and kwvals.""" + circ = RealAmplitudes(num_qubits=2, reps=1) + inst = primitive(backend=get_mocked_backend()) + + with self.subTest("0-d"): + param_vals = np.linspace(0, 1, 4) + kwvals = {tuple(circ.parameters[:2]): param_vals[:2]} + barray = BindingsArray(vals=param_vals[2:], kwvals=kwvals) + task = (circ, "ZZ", barray) if isinstance(inst, EstimatorV2) else (circ, barray) + inst.run(task) + + with self.subTest("n-d"): + kwvals = {tuple(circ.parameters[:2]): np.random.random((2, 3, 2))} + barray = BindingsArray(vals=np.random.random((2, 3, 2)), kwvals=kwvals) + task = (circ, "ZZ", barray) if isinstance(inst, EstimatorV2) else (circ, barray) + inst.run(task) + + @data(EstimatorV2) + def test_parameters_multiple_circuits(self, primitive): + """Test multiple parameters for multiple circuits.""" + circuits = [ + QuantumCircuit(2), + RealAmplitudes(num_qubits=2, reps=1), + RealAmplitudes(num_qubits=3, reps=1), + ] + + param_vals = [ + ( + [], + np.random.uniform(size=(4,)), + np.random.uniform(size=(6,)), + ), + ( + [], + np.random.random((2, 4)), + np.random.random((2, 6)), + ), + ] - @data(EstimatorV2, SamplerV2) - def test_run_overwrite_options(self, primitive): + inst = primitive(backend=get_mocked_backend()) + for all_params in param_vals: + with self.subTest(all_params=all_params): + tasks = [] + for circ, circ_params in zip(circuits, all_params): + tasklet = ( + (circ, "Z" * circ.num_qubits, circ_params) + if isinstance(inst, EstimatorV2) + else (circ, circ_params) + ) + tasks.append(tasklet) + inst.run(tasks) + + @data(EstimatorV2) + def test_run_updated_options(self, primitive): """Test run using overwritten options.""" session = MagicMock(spec=MockSession) options_vars = [ @@ -301,17 +375,21 @@ def test_run_overwrite_options(self, primitive): } }, ), + ( + {"skip_transpilation": True}, + {"skip_transpilation": True}, + ), ] - opt_cls = primitive._OPTIONS_CLASS + for options, expected in options_vars: with self.subTest(options=options): inst = primitive(session=session) - inst.run(**get_primitive_inputs(inst), **options) + inst.options.update(**options) + inst.run(**get_primitive_inputs(inst)) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal(inputs, expected) - self.assertDictEqual(asdict(inst.options), asdict(opt_cls())) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_run_overwrite_runtime_options(self, primitive): """Test run using overwritten runtime options.""" session = MagicMock(spec=MockSession) @@ -324,51 +402,52 @@ def test_run_overwrite_runtime_options(self, primitive): for options in options_vars: with self.subTest(options=options): inst = primitive(session=session) - inst.run(**get_primitive_inputs(inst), **options) + inst.options.update(**options) + inst.run(**get_primitive_inputs(inst)) rt_options = session.run.call_args.kwargs["options"] self._assert_dict_partially_equal(rt_options, options) @combine( - primitive=[EstimatorV2, SamplerV2], + primitive=[EstimatorV2], exp_opt=[{"foo": "bar"}, {"transpilation": {"foo": "bar"}}], ) def test_run_experimental_options(self, primitive, exp_opt): """Test specifying arbitrary options in run.""" session = MagicMock(spec=MockSession) inst = primitive(session=session) - inst.run(**get_primitive_inputs(inst), experimental=exp_opt) + inst.options.experimental = exp_opt + inst.run(**get_primitive_inputs(inst)) inputs = session.run.call_args.kwargs["inputs"] self._assert_dict_partially_equal(inputs, exp_opt) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_run_unset_options(self, primitive): """Test running with unset options.""" session = MagicMock(spec=MockSession) inst = primitive(session=session) inst.run(**get_primitive_inputs(inst)) inputs = session.run.call_args.kwargs["inputs"] - for fld in ["circuits", "observables", "parameters", "parameter_values", "_experimental"]: + for fld in ["tasks", "_experimental"]: inputs.pop(fld, None) expected = {"skip_transpilation": False, "execution": {"init_qubits": True}, "version": 2} self.assertDictEqual(inputs, expected) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_run_multiple_different_options(self, primitive): """Test multiple runs with different options.""" - opt_cls = primitive._OPTIONS_CLASS session = MagicMock(spec=MockSession) - inst = primitive(session=session) - inst.run(**get_primitive_inputs(inst), shots=100) - inst.run(**get_primitive_inputs(inst), shots=200) + inst = primitive(session=session, options={"shots": 100}) + inst.run(**get_primitive_inputs(inst)) + inst.options.update(shots=200) + inst.run(**get_primitive_inputs(inst)) kwargs_list = session.run.call_args_list for idx, shots in zip([0, 1], [100, 200]): self.assertEqual(kwargs_list[idx][1]["inputs"]["execution"]["shots"], shots) - self.assertDictEqual(asdict(inst.options), asdict(opt_cls())) def test_run_same_session(self): """Test multiple runs within a session.""" num_runs = 5 - primitives = [EstimatorV2, SamplerV2] + primitives = [EstimatorV2] session = MagicMock(spec=MockSession) for idx in range(num_runs): cls = primitives[idx % len(primitives)] @@ -376,38 +455,38 @@ def test_run_same_session(self): inst.run(**get_primitive_inputs(inst)) self.assertEqual(session.run.call_count, num_runs) - @data(EstimatorV2, SamplerV2) - def test_set_options(self, primitive): + @combine( + primitive=[EstimatorV2], + new_opts=[ + {"optimization_level": 2}, + {"optimization_level": 3, "shots": 200}, + ], + ) + def test_set_options(self, primitive, new_opts): """Test set options.""" - opt_cls = primitive._OPTIONS_CLASS + opt_cls = primitive._options_class options = opt_cls(optimization_level=1, execution={"shots": 100}) - new_options = [ - ({"optimization_level": 2}, opt_cls()), - ({"optimization_level": 3, "shots": 200}, opt_cls()), - ] session = MagicMock(spec=MockSession) - for new_opt, new_str in new_options: - with self.subTest(new_opt=new_opt): - inst = primitive(session=session, options=options) - inst.set_options(**new_opt) - # Make sure the values are equal. - inst_options = asdict(inst.options) - self.assertTrue( - flat_dict_partially_equal(inst_options, new_opt), - f"inst_options={inst_options}, new_opt={new_opt}", - ) - # Make sure the structure didn't change. - self.assertTrue( - dict_keys_equal(inst_options, asdict(new_str)), - f"inst_options={inst_options}, new_str={new_str}", - ) + inst = primitive(session=session, options=options) + inst.options.update(**new_opts) + # Make sure the values are equal. + inst_options = asdict(inst.options) + self.assertTrue( + flat_dict_partially_equal(inst_options, new_opts), + f"inst_options={inst_options}, new_opt={new_opts}", + ) + # Make sure the structure didn't change. + self.assertTrue( + dict_keys_equal(inst_options, asdict(opt_cls())), + f"inst_options={inst_options}, original={opt_cls()}", + ) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_accept_level_1_options(self, primitive): """Test initializing options properly when given on level 1.""" - opt_cls = primitive._OPTIONS_CLASS + opt_cls = primitive._options_class options_dicts = [ {}, {"shots": 10}, @@ -446,9 +525,9 @@ def test_default_error_levels(self): inst = cls(session=session, options=options) if isinstance(inst, Estimator): - inst.run(self.qx, observables=self.obs) + inst.run(self.circ, observables=self.obs) else: - inst.run(self.qx) + inst.run(self.circ) if sys.version_info >= (3, 8): inputs = session.run.call_args.kwargs["inputs"] @@ -466,7 +545,7 @@ def test_default_error_levels(self): session.service.backend().configuration().simulator = False inst = cls(session=session) - inst.run(self.qx, observables=self.obs) + inst.run(self.circ, observables=self.obs) if sys.version_info >= (3, 8): inputs = session.run.call_args.kwargs["inputs"] else: @@ -483,7 +562,7 @@ def test_default_error_levels(self): session.service.backend().configuration().simulator = True inst = cls(session=session) - inst.run(self.qx, observables=self.obs) + inst.run(self.circ, observables=self.obs) if sys.version_info >= (3, 8): inputs = session.run.call_args.kwargs["inputs"] else: @@ -495,7 +574,7 @@ def test_default_error_levels(self): ) self.assertEqual(inputs["resilience_level"], 0) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_raise_faulty_qubits(self, primitive): """Test faulty qubits is raised.""" fake_backend = FakeManila() @@ -512,18 +591,19 @@ def test_raise_faulty_qubits(self, primitive): service.backend.return_value = ibm_backend session = Session(service=service, backend=fake_backend.name) - inst = primitive(session=session) - if isinstance(inst, IBMBaseEstimator): # TODO fix for sampler - inputs = {"circuits": transpiled, "observables": observable} + inst = primitive(session=session, options={"skip_transpilation": True}) + + if isinstance(inst, IBMBaseEstimator): + task = (transpiled, observable) else: transpiled.measure_all() - inputs = {"circuits": transpiled} + task = (transpiled,) with self.assertRaises(ValueError) as err: - inst.run(**inputs, skip_transpilation=True) + inst.run(tasks=task) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_raise_faulty_qubits_many(self, primitive): """Test faulty qubits is raised if one circuit uses it.""" fake_backend = FakeManila() @@ -543,19 +623,19 @@ def test_raise_faulty_qubits_many(self, primitive): service.backend.return_value = ibm_backend session = Session(service=service, backend=fake_backend.name) - inst = primitive(session=session) + inst = primitive(session=session, options={"skip_transpilation": True}) if isinstance(inst, IBMBaseEstimator): - inputs = {"circuits": transpiled, "observables": [observable, observable]} + tasks = [(transpiled[0], observable), (transpiled[1], observable)] else: for circ in transpiled: circ.measure_all() - inputs = {"circuits": transpiled} + tasks = [(transpiled[0],), (transpiled[1],)] with self.assertRaises(ValueError) as err: - inst.run(**inputs, skip_transpilation=True) + inst.run(tasks=tasks) self.assertIn(f"faulty qubit {faulty_qubit}", str(err.exception)) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_raise_faulty_edge(self, primitive): """Test faulty edge is raised.""" fake_backend = FakeManila() @@ -572,19 +652,19 @@ def test_raise_faulty_edge(self, primitive): service.backend.return_value = ibm_backend session = Session(service=service, backend=fake_backend.name) - inst = primitive(session=session) + inst = primitive(session=session, options={"skip_transpilation": True}) if isinstance(inst, IBMBaseEstimator): - inputs = {"circuits": transpiled, "observables": observable} + task = (transpiled, observable) else: transpiled.measure_all() - inputs = {"circuits": transpiled} + task = (transpiled,) with self.assertRaises(ValueError) as err: - inst.run(**inputs, skip_transpilation=True) + inst.run(tasks=task) self.assertIn("cx", str(err.exception)) self.assertIn(f"faulty edge {tuple(edge_qubits)}", str(err.exception)) - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_faulty_qubit_not_used(self, primitive): """Test faulty qubit is not raise if not used.""" fake_backend = FakeManila() @@ -601,18 +681,18 @@ def test_faulty_qubit_not_used(self, primitive): service.backend.return_value = ibm_backend session = Session(service=service, backend=fake_backend.name) - inst = primitive(session=session) + inst = primitive(session=session, options={"skip_transpilation": True}) if isinstance(inst, IBMBaseEstimator): - inputs = {"circuits": transpiled, "observables": observable} + task = (transpiled, observable) else: transpiled.measure_active(inplace=True) - inputs = {"circuits": transpiled} + task = (transpiled,) with patch.object(Session, "run") as mock_run: - inst.run(**inputs, skip_transpilation=True) + inst.run(task) mock_run.assert_called_once() - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_faulty_edge_not_used(self, primitive): """Test faulty edge is not raised if not used.""" fake_backend = FakeManila() @@ -631,18 +711,18 @@ def test_faulty_edge_not_used(self, primitive): service.backend.return_value = ibm_backend session = Session(service=service, backend=fake_backend.name) - inst = primitive(session=session) + inst = primitive(session=session, options={"skip_transpilation": True}) if isinstance(inst, IBMBaseEstimator): - inputs = {"circuits": transpiled, "observables": observable} + task = (transpiled, observable) else: transpiled.measure_all() - inputs = {"circuits": transpiled} + task = (transpiled,) with patch.object(Session, "run") as mock_run: - inst.run(**inputs, skip_transpilation=True) + inst.run(task) mock_run.assert_called_once() - @data(EstimatorV2, SamplerV2) + @data(EstimatorV2) def test_no_raise_skip_transpilation(self, primitive): """Test faulty qubits and edges are not raise if not skipping.""" fake_backend = FakeManila() @@ -664,13 +744,13 @@ def test_no_raise_skip_transpilation(self, primitive): inst = primitive(session=session) if isinstance(inst, IBMBaseEstimator): - inputs = {"circuits": transpiled, "observables": observable} + task = (transpiled, observable) else: transpiled.measure_all() - inputs = {"circuits": transpiled} + task = (transpiled,) with patch.object(Session, "run") as mock_run: - inst.run(**inputs) + inst.run(task) mock_run.assert_called_once() def _update_dict(self, dict1, dict2): @@ -725,9 +805,9 @@ def test_qctrl_supported_values_for_options(self): with self.subTest(msg=f"{cls}, {options}"): inst = cls(session=session) if isinstance(inst, Estimator): - _ = inst.run(self.qx, observables=self.obs, **options) + _ = inst.run(self.circ, observables=self.obs, **options) else: - _ = inst.run(self.qx, **options) + _ = inst.run(self.circ, **options) @skip("Q-Ctrl does not support v2 yet") def test_qctrl_unsupported_values_for_options(self): @@ -748,8 +828,8 @@ def test_qctrl_unsupported_values_for_options(self): inst = cls(session=session) with self.assertRaises(ValueError) as exc: if isinstance(inst, Sampler): - _ = inst.run(self.qx, **bad_opt) + _ = inst.run(self.circ, **bad_opt) else: - _ = inst.run(self.qx, observables=self.obs, **bad_opt) + _ = inst.run(self.circ, observables=self.obs, **bad_opt) self.assertIn(expected_message, str(exc.exception)) diff --git a/test/unit/test_options.py b/test/unit/test_options.py index ec4d466cc..29d2f7072 100644 --- a/test/unit/test_options.py +++ b/test/unit/test_options.py @@ -523,3 +523,28 @@ def test_invalid_options(self, opt_cls, opt): with self.assertRaises(ValidationError) as exc: opt_cls(**opt) self.assertIn(list(opt.keys())[0], str(exc.exception)) + + @data( + {"resilience_level": 2}, + {"max_execution_time": 200}, + {"resilience_level": 2, "transpilation": {"initial_layout": [1, 2]}}, + {"shots": 1024, "seed_simulator": 42}, + {"resilience_level": 2, "shots": 2048, "initial_layout": [3, 4]}, + { + "initial_layout": [1, 2], + "transpilation": {"layout_method": "trivial"}, + "log_level": "INFO", + }, + ) + def test_update_options(self, new_opts): + """Test update method.""" + options = EstimatorOptions() + options.update(**new_opts) + + # Make sure the values are equal. + self.assertTrue( + flat_dict_partially_equal(asdict(options), new_opts), + f"new_opts={new_opts}, combined={options}", + ) + # Make sure the structure didn't change. + self.assertTrue(dict_keys_equal(asdict(options), asdict(EstimatorOptions()))) diff --git a/test/unit/test_sampler.py b/test/unit/test_sampler.py index 2e50df7e0..410485bf6 100644 --- a/test/unit/test_sampler.py +++ b/test/unit/test_sampler.py @@ -12,13 +12,17 @@ """Tests for sampler class.""" +from unittest import skip from unittest.mock import MagicMock from ddt import data, ddt +import numpy as np from qiskit import QuantumCircuit +from qiskit.circuit.library import RealAmplitudes from qiskit.test.reference_circuits import ReferenceCircuits from qiskit_ibm_runtime import Sampler, Session, SamplerV2, SamplerOptions +from qiskit_ibm_runtime.qiskit.primitives import SamplerTask from ..ibm_test_case import IBMTestCase from .mock.fake_runtime_service import FakeRuntimeService @@ -47,6 +51,7 @@ def test_unsupported_values_for_sampler_options(self): self.assertIn(list(bad_opt.keys())[0], str(exc.exception)) +@skip("Skip until SamplerV2 is supported") @ddt class TestSamplerV2(IBMTestCase): """Class for testing the Estimator class.""" @@ -55,6 +60,28 @@ def setUp(self) -> None: super().setUp() self.circuit = QuantumCircuit(1, 1) + @data( + [(RealAmplitudes(num_qubits=2, reps=1), [1, 2, 3, 4])], + [(RealAmplitudes(num_qubits=2, reps=1), [1, 2, 3, 4])], + [(QuantumCircuit(2),)], + [(RealAmplitudes(num_qubits=1, reps=1), [1, 2]), (QuantumCircuit(3),)], + ) + def test_run_program_inputs(self, in_tasks): + """Verify program inputs are correct.""" + session = MagicMock(spec=MockSession) + inst = SamplerV2(session=session) + inst.run(in_tasks) + input_params = session.run.call_args.kwargs["inputs"] + self.assertIn("tasks", input_params) + tasks_param = input_params["tasks"] + for a_task_param, an_in_taks in zip(tasks_param, in_tasks): + self.assertIsInstance(a_task_param, SamplerTask) + # Check circuit + self.assertEqual(a_task_param.circuit, an_in_taks[0]) + # Check parameter values + an_input_params = an_in_taks[1] if len(an_in_taks) == 2 else [] + np.allclose(a_task_param.parameter_values.vals, an_input_params) + @data( {"optimization_level": 4}, {"resilience_level": 1}, {"resilience": {"zne_mitigation": True}} ) @@ -66,16 +93,19 @@ def test_unsupported_values_for_sampler_options(self, opt): ) as session: inst = SamplerV2(session=session) with self.assertRaises(ValueError) as exc: - _ = inst.run(self.circuit, **opt) + inst.options.update(**opt) self.assertIn(list(opt.keys())[0], str(exc.exception)) def test_run_default_options(self): """Test run using default options.""" session = MagicMock(spec=MockSession) options_vars = [ - (SamplerOptions(dynamical_decoupling="XX"), {"dynamical_decoupling": "XX"}), ( - SamplerOptions(optimization_level=3), + SamplerOptions(dynamical_decoupling="XX"), # pylint: disable=unexpected-keyword-arg + {"dynamical_decoupling": "XX"}, + ), + ( + SamplerOptions(optimization_level=3), # pylint: disable=unexpected-keyword-arg {"transpilation": {"optimization_level": 3}}, ), ( @@ -92,7 +122,7 @@ def test_run_default_options(self): for options, expected in options_vars: with self.subTest(options=options): inst = SamplerV2(session=session, options=options) - inst.run(self.circuit) + inst.run((self.circuit,)) inputs = session.run.call_args.kwargs["inputs"] self.assertTrue( dict_paritally_equal(inputs, expected), diff --git a/test/utils.py b/test/utils.py index 4bc4e08ce..33c6ed8bf 100644 --- a/test/utils.py +++ b/test/utils.py @@ -328,10 +328,10 @@ def get_primitive_inputs(primitive, num_sets=1): obs = SparsePauliOp.from_list([("IZ", 1)]) if isinstance(primitive, Estimator): - return {"circuits": [circ] * num_sets, "observables": [obs] * num_sets} + return {"tasks": [(circ, [obs])] * num_sets} circ.measure_all() - return {"circuits": [circ] * num_sets} + return {"tasks": [(circ,)] * num_sets} class MockSession(Session):