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 d34fc8d9c..f6fd81893 100644 --- a/qiskit_ibm_runtime/base_primitive.py +++ b/qiskit_ibm_runtime/base_primitive.py @@ -145,7 +145,7 @@ def _run(self, tasks: Union[list[EstimatorTask], list[SamplerTask]]) -> RuntimeJ inputs=primitive_inputs, options=runtime_options, callback=options_dict.get("environment", {}).get("callback", None), - result_decoder=DEFAULT_DECODERS.get((self._program_id(), self.version)), + result_decoder=DEFAULT_DECODERS.get(self._program_id()), ) if self._backend: @@ -158,7 +158,7 @@ def _run(self, tasks: Union[list[EstimatorTask], list[SamplerTask]]) -> RuntimeJ options=runtime_options, inputs=primitive_inputs, callback=options_dict.get("environment", {}).get("callback", None), - result_decoder=DEFAULT_DECODERS.get((self._program_id(), self.version)), + result_decoder=DEFAULT_DECODERS.get(self._program_id()), ) @property diff --git a/qiskit_ibm_runtime/constants.py b/qiskit_ibm_runtime/constants.py index 215dc4e1a..3a5568cab 100644 --- a/qiskit_ibm_runtime/constants.py +++ b/qiskit_ibm_runtime/constants.py @@ -41,6 +41,4 @@ "estimator": [ResultDecoder, EstimatorResultDecoder], "circuit-runner": RunnerResult, "qasm3-runner": RunnerResult, - ("sampler", 2): [ResultDecoder, SamplerResultDecoder], - ("estimator", 2): [ResultDecoder, EstimatorResultDecoder], } diff --git a/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py b/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py index c6929655a..4aa5096b8 100644 --- a/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py +++ b/qiskit_ibm_runtime/qiskit/primitives/bindings_array.py @@ -16,9 +16,9 @@ """ from __future__ import annotations -from collections.abc import Iterable, Sequence +from collections.abc import Iterable from itertools import chain, product -from typing import Dict, List, Optional, Tuple, Union, Mapping +from typing import Dict, List, Optional, Tuple, Union, Mapping, Sequence import numpy as np from numpy.typing import ArrayLike, NDArray @@ -102,8 +102,6 @@ def __init__( """ super().__init__() - if vals is None and kwvals is None and shape is None: - raise ValueError("Must specify a shape if no values are present") if vals is None: vals = [] if kwvals is None: @@ -162,6 +160,76 @@ 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. @@ -229,13 +297,13 @@ def ravel(self) -> BindingsArray: """ return self.reshape(self.size) - def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: + 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. + *shape: The shape of the returned bindings array. Returns: A new bindings array. @@ -243,12 +311,21 @@ def reshape(self, shape: Union[int, Iterable[int]]) -> BindingsArray: Raises: ValueError: If the provided shape has a different product than the current size. """ - shape = (shape, -1) if isinstance(shape, int) else (*shape, -1) - if np.prod(shape[:-1]).astype(int) != self.size: - raise ValueError("Reshaping cannot change the total number of elements.") - vals = [val.reshape(shape) for val in self._vals] - kwvals = {params: val.reshape(shape) for params, val in self._kwvals.items()} - return BindingsArray(vals, kwvals, shape[:-1]) + 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: @@ -346,8 +423,8 @@ def examine_array(*possible_shapes): if len(parameters) > 1: # here, the last dimension _has_ to be over parameters examine_array(val.shape[:-1]) - elif val.shape[-1] != 1: - # here, if the last dimension is not 1 then the shape is the shape + 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 @@ -355,6 +432,8 @@ def examine_array(*possible_shapes): 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 diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index 6ef274fad..ff4e0b4a4 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) 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..5d106ef1d 100644 --- a/qiskit_ibm_runtime/runtime_job.py +++ b/qiskit_ibm_runtime/runtime_job.py @@ -102,6 +102,7 @@ def __init__( image: Optional[str] = "", session_id: Optional[str] = None, tags: Optional[List] = None, + version: Optional[int] = None, ) -> None: """RuntimeJob constructor. @@ -119,6 +120,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 +137,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 +229,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 self.program_id in ["estimator"]: + if not self._version: + self._version = self.inputs.get("version", 1) + version_param["version"] = self._version + + return _decoder.decode(result_raw, self._version) if result_raw else None # type: ignore def cancel(self) -> None: """Cancel the job. diff --git a/qiskit_ibm_runtime/utils/estimator_result_decoder.py b/qiskit_ibm_runtime/utils/estimator_result_decoder.py index f9ade0b07..d5546d2ea 100644 --- a/qiskit_ibm_runtime/utils/estimator_result_decoder.py +++ b/qiskit_ibm_runtime/utils/estimator_result_decoder.py @@ -25,22 +25,20 @@ 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) - return EstimatorResult( - values=np.asarray(decoded["values"]), - metadata=decoded["metadata"], - ) - - -class EstimatorV2ResultDecoder(ResultDecoder): - """Class used to decode v2 estimator results""" - - @classmethod - def decode(cls, raw_result: str) -> List[TaskResult]: - """Convert the result to a list of TaskResult.""" - 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"],