diff --git a/cirq-ionq/cirq_ionq/job.py b/cirq-ionq/cirq_ionq/job.py index c3f1636190f..849f8a810d2 100644 --- a/cirq-ionq/cirq_ionq/job.py +++ b/cirq-ionq/cirq_ionq/job.py @@ -198,7 +198,12 @@ def results( polling_seconds: int = 1, sharpen: bool | None = None, extra_query_params: dict | None = None, - ) -> list[results.QPUResult] | list[results.SimulatorResult]: + ) -> ( + results.QPUResult + | results.SimulatorResult + | list[results.QPUResult] + | list[results.SimulatorResult] + ): """Polls the IonQ api for results. Args: @@ -209,9 +214,10 @@ def results( extra_query_params: Specify any parameters to include in the request. Returns: - Either a list of `cirq_ionq.QPUResult` or a list of `cirq_ionq.SimulatorResult` - depending on whether the job was running on an actual quantum processor or a - simulator. + Either a single `cirq_ionq.QPUResult` / `cirq_ionq.SimulatorResult` + (for a single-circuit job) or a `list` of such results (for a + batch job). The list order for batch jobs corresponds to the + order of the input circuits. Raises: IonQUnsuccessfulJob: If the job has failed, been canceled, or deleted. @@ -219,6 +225,16 @@ def results( RuntimeError: If the job reported that it had failed on the server, or the job had an unknown status. TimeoutError: If the job timed out at the server. + + Notes: + * IonQ returns results in little endian; Cirq presents them in + big endian. + * If your code previously assumed a list, use: + r = job.results() + results_list = r if isinstance(r, list) else [r] + If your code previously assumed a single result, use: + r = job.results() + r0 = r[0] if isinstance(r, list) else r """ time_waited_seconds = 0 while time_waited_seconds < timeout_seconds: @@ -245,11 +261,10 @@ def results( job_id=self.job_id(), sharpen=sharpen, extra_query_params=extra_query_params ) + # is this a batch run (dict-of-dicts) or a single circuit? some_inner_value = next(iter(backend_results.values())) - if isinstance(some_inner_value, dict): - histograms = backend_results.values() - else: - histograms = [backend_results] + is_batch = isinstance(some_inner_value, dict) + histograms = list(backend_results.values()) if is_batch else [backend_results] # IonQ returns results in little endian, but # Cirq prefers to use big endian, so we convert. @@ -270,7 +285,11 @@ def results( measurement_dict=self.measurement_dict(circuit_index=circuit_index), ) ) - return big_endian_results_qpu + return ( + big_endian_results_qpu + if len(big_endian_results_qpu) > 1 + else big_endian_results_qpu[0] + ) else: big_endian_results_sim: list[results.SimulatorResult] = [] for circuit_index, histogram in enumerate(histograms): @@ -286,7 +305,11 @@ def results( repetitions=self.repetitions(), ) ) - return big_endian_results_sim + return ( + big_endian_results_sim + if len(big_endian_results_sim) > 1 + else big_endian_results_sim[0] + ) def cancel(self): """Cancel the given job. diff --git a/cirq-ionq/cirq_ionq/job_test.py b/cirq-ionq/cirq_ionq/job_test.py index 8572769c3a0..ae502f42f5a 100644 --- a/cirq-ionq/cirq_ionq/job_test.py +++ b/cirq-ionq/cirq_ionq/job_test.py @@ -99,7 +99,7 @@ def test_job_results_qpu(): assert "foo" in str(w[0].message) assert "bar" in str(w[1].message) expected = ionq.QPUResult({0: 600, 1: 400}, 2, {'a': [0, 1]}) - assert results[0] == expected + assert results == expected def test_batch_job_results_qpu(): @@ -148,7 +148,7 @@ def test_job_results_rounding_qpu(): job = ionq.Job(mock_client, job_dict) expected = ionq.QPUResult({0: 3, 1: 4997}, 2, {'a': [0, 1]}) results = job.results() - assert results[0] == expected + assert results == expected def test_job_results_failed(): @@ -179,7 +179,7 @@ def test_job_results_qpu_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={}) + assert results == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={}) def test_batch_job_results_qpu_endianness(): @@ -200,7 +200,7 @@ def test_batch_job_results_qpu_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={'a': [0, 1]}) + assert results == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={'a': [0, 1]}) def test_job_results_qpu_target_endianness(): @@ -216,7 +216,7 @@ def test_job_results_qpu_target_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={}) + assert results == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={}) def test_batch_job_results_qpu_target_endianness(): @@ -238,7 +238,7 @@ def test_batch_job_results_qpu_target_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={'a': [0, 1]}) + assert results == ionq.QPUResult({0: 600, 2: 400}, 2, measurement_dict={'a': [0, 1]}) @mock.patch('time.sleep', return_value=None) @@ -256,7 +256,7 @@ def test_job_results_poll(mock_sleep): mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} job = ionq.Job(mock_client, ready_job) results = job.results(polling_seconds=0) - assert results[0] == ionq.QPUResult({0: 600, 1: 400}, 1, measurement_dict={}) + assert results == ionq.QPUResult({0: 600, 1: 400}, 1, measurement_dict={}) mock_sleep.assert_called_once() @@ -294,7 +294,7 @@ def test_job_results_simulator(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.SimulatorResult({0: 0.6, 1: 0.4}, 1, {}, 100) + assert results == ionq.SimulatorResult({0: 0.6, 1: 0.4}, 1, {}, 100) def test_batch_job_results_simulator(): @@ -336,7 +336,7 @@ def test_job_results_simulator_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.SimulatorResult({0: 0.6, 2: 0.4}, 2, {}, 100) + assert results == ionq.SimulatorResult({0: 0.6, 2: 0.4}, 2, {}, 100) def test_batch_job_results_simulator_endianness(): @@ -357,7 +357,7 @@ def test_batch_job_results_simulator_endianness(): } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results[0] == ionq.SimulatorResult({0: 0.6, 2: 0.4}, 2, {'a': [0, 1]}, 1000) + assert results == ionq.SimulatorResult({0: 0.6, 2: 0.4}, 2, {'a': [0, 1]}, 1000) def test_job_sharpen_results(): @@ -372,7 +372,7 @@ def test_job_sharpen_results(): } job = ionq.Job(mock_client, job_dict) results = job.results(sharpen=False) - assert results[0] == ionq.SimulatorResult({0: 60, 1: 40}, 1, {}, 100) + assert results == ionq.SimulatorResult({0: 60, 1: 40}, 1, {}, 100) def test_job_cancel(): diff --git a/cirq-ionq/cirq_ionq/sampler.py b/cirq-ionq/cirq_ionq/sampler.py index de0ed841bd9..b4a46fcec79 100644 --- a/cirq-ionq/cirq_ionq/sampler.py +++ b/cirq-ionq/cirq_ionq/sampler.py @@ -15,7 +15,6 @@ from __future__ import annotations -import itertools from collections.abc import Sequence from typing import TYPE_CHECKING @@ -107,7 +106,12 @@ def run_sweep( job_results = [job.results(timeout_seconds=self._timeout_seconds) for job in jobs] else: job_results = [job.results() for job in jobs] - flattened_job_results = list(itertools.chain.from_iterable(job_results)) + flattened_job_results: list[results.QPUResult | results.SimulatorResult] = [] + for res in job_results: + if isinstance(res, list): + flattened_job_results.extend(res) + else: + flattened_job_results.append(res) cirq_results = [] for result, params in zip(flattened_job_results, resolvers): if isinstance(result, results.QPUResult): diff --git a/cirq-ionq/cirq_ionq/sampler_test.py b/cirq-ionq/cirq_ionq/sampler_test.py index 24577ff93e3..52d3ef27e5c 100644 --- a/cirq-ionq/cirq_ionq/sampler_test.py +++ b/cirq-ionq/cirq_ionq/sampler_test.py @@ -154,3 +154,38 @@ def test_sampler_run_sweep(): _, kwargs = call assert kwargs["repetitions"] == 4 assert kwargs["target"] == 'qpu' + + +def test_sampler_run_sweep_batched_job_results(): + mock_service = mock.MagicMock() + job_dict = { + 'id': '1', + 'status': 'completed', + 'stats': {'qubits': '1'}, + 'backend': 'qpu', + 'metadata': {'shots': 4, 'measurement0': f'a{chr(31)}0'}, + } + + job = ionq.Job(client=mock_service, job_dict=job_dict) + mock_service.create_job.return_value = job + + # Create dummy results + result1 = ionq.QPUResult(counts={0: 4}, num_qubits=1, measurement_dict={'a': [0]}) + result2 = ionq.QPUResult(counts={1: 4}, num_qubits=1, measurement_dict={'a': [0]}) + + # Mock job.results to return a list + with mock.patch.object(ionq.Job, 'results', return_value=[result1, result2]): + sampler = ionq.Sampler(service=mock_service, target='qpu') + q0 = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X(q0), cirq.measure(q0, key='a')) + + # We pass 1 resolver, but job returns 2 results (list). + # This triggers `flattened_job_results.extend(res)`. + results = sampler.run_sweep(program=circuit, params=[cirq.ParamResolver({})], repetitions=4) + + # Even though we got 2 results from the job, we only have 1 resolver, + # so zip() truncates to 1 result. + assert len(results) == 1 + # result1 counts={0: 4}, so we expect four 0s. + # measurements is (repetitions, qubits), so [[0], [0], [0], [0]] + assert results[0].measurements['a'].tolist() == [[0], [0], [0], [0]] diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index 780338c20d9..712a77ccec7 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -157,13 +157,12 @@ def run( dry_run=dry_run, extra_query_params=extra_query_params, ).results(sharpen=sharpen) - if isinstance(job_results[0], results.QPUResult): - return job_results[0].to_cirq_result(params=cirq.ParamResolver(param_resolver)) - if isinstance(job_results[0], results.SimulatorResult): - return job_results[0].to_cirq_result( - params=cirq.ParamResolver(param_resolver), seed=seed - ) - raise NotImplementedError(f"Unrecognized job result type '{type(job_results[0])}'.") + result = job_results[0] if isinstance(job_results, list) else job_results + if isinstance(result, results.QPUResult): + return result.to_cirq_result(params=cirq.ParamResolver(param_resolver)) + if isinstance(result, results.SimulatorResult): + return result.to_cirq_result(params=cirq.ParamResolver(param_resolver), seed=seed) + raise NotImplementedError(f"Unrecognized job result type '{type(result)}'.") def run_batch( self, @@ -234,8 +233,9 @@ def run_batch( extra_query_params=extra_query_params, ).results(sharpen=sharpen) + job_results_list = job_results if isinstance(job_results, list) else [job_results] cirq_results = [] - for job_result in job_results: + for job_result in job_results_list: if isinstance(job_result, results.QPUResult): cirq_results.append( job_result.to_cirq_result(params=cirq.ParamResolver(param_resolver)) diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index ce2957c1801..c3d37e2fae7 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -15,6 +15,7 @@ from __future__ import annotations import datetime +import json import os from unittest import mock @@ -296,3 +297,131 @@ def test_service_remote_host_default(): def test_service_remote_host_from_env_var_cirq_ionq_precedence(): service = ionq.Service(api_key='tomyheart') assert service.remote_host == 'http://example.com' + + +def test_service_run_unwraps_single_result_list(): + """`Service.run` should unwrap `[result]` to `result`.""" + # set up a real Service object (we'll monkey-patch its create_job) + service = ionq.Service(remote_host="http://example.com", api_key="key") + + # simple 1-qubit circuit + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X(q), cirq.measure(q, key="m")) + + # fabricate a QPUResult and wrap it in a list to mimic an erroneous behavior + qpu_result = ionq.QPUResult(counts={1: 1}, num_qubits=1, measurement_dict={"m": [0]}) + mock_job = mock.MagicMock() + mock_job.results.return_value = [qpu_result] # <- list of length-1 + + # monkey-patch create_job so Service.run sees our mock_job + with mock.patch.object(service, "create_job", return_value=mock_job): + out = service.run(circuit=circuit, repetitions=1, target="qpu") + + # expected Cirq result after unwrapping and conversion + expected = qpu_result.to_cirq_result(params=cirq.ParamResolver({})) + + assert out == expected + mock_job.results.assert_called_once() + + +@pytest.mark.parametrize("target", ["qpu", "simulator"]) +def test_run_batch_preserves_order(target): + """``Service.run_batch`` must return results in the same order as the + input ``circuits`` list, regardless of how the IonQ API happens to order + its per-circuit results. + """ + + # Service with a fully mocked HTTP client. + service = ionq.Service(remote_host="http://example.com", api_key="key") + client = mock.MagicMock() + service._client = client + + # Three trivial 1-qubit circuits, each measuring under a unique key. + keys = ["a", "b", "c"] + q = cirq.LineQubit(0) + circuits = [cirq.Circuit(cirq.measure(q, key=k)) for k in keys] + + client.create_job.return_value = {"id": "job_id", "status": "ready"} + + client.get_job.return_value = { + "id": "job_id", + "status": "completed", + "backend": target, + "qubits": "1", + "metadata": { + "shots": "1", + "measurements": json.dumps([{"measurement0": f"{k}\u001f0"} for k in keys]), + "qubit_numbers": json.dumps([1, 1, 1]), + }, + } + + # Intentionally scramble the order returned by the API: b, a, c. + client.get_results.return_value = { + "res_b": {"0": "1"}, + "res_a": {"0": "1"}, + "res_c": {"0": "1"}, + } + + results = service.run_batch(circuits, repetitions=1, target=target) + + # The order of measurement keys in the results should match the input + # circuit order exactly (a, b, c). + assert [next(iter(r.measurements)) for r in results] == keys + + # Smoke-test on the mocked client usage. + client.create_job.assert_called_once() + client.get_results.assert_called_once() + + +def test_service_run_seed(): + """Test create_job in another way, for more complete coverage.""" + # Set up a real Service object (we'll monkey-patch its create_job). + service = ionq.Service(remote_host="http://example.com", api_key="key") + + # Setup the job to return a SimulatorResult. + mock_job = mock.MagicMock() + mock_simulator_result = mock.MagicMock(spec=ionq.SimulatorResult) + mock_job.results.return_value = mock_simulator_result + + # We need to mock create_job on the service because service.run calls self.create_job. + with mock.patch.object(service, 'create_job', return_value=mock_job): + circuit = cirq.Circuit(cirq.X(cirq.LineQubit(0))) + service.run(circuit, repetitions=1, target='simulator', seed=123) + + mock_simulator_result.to_cirq_result.assert_called_once() + kwargs = mock_simulator_result.to_cirq_result.call_args[1] + assert kwargs['seed'] == 123 + + +def test_service_run_returns_list_defensive(): + """Cover the case where job.results() returns a list for a single run.""" + service = ionq.Service(remote_host="http://example.com", api_key="key") + mock_job = mock.MagicMock() + # Return a list containing one QPUResult + qpu_result = ionq.QPUResult(counts={1: 1}, num_qubits=1, measurement_dict={"m": [0]}) + mock_job.results.return_value = [qpu_result] + + with mock.patch.object(service, 'create_job', return_value=mock_job): + circuit = cirq.Circuit(cirq.X(cirq.LineQubit(0))) + # This should handle the list and extract the first element + result = service.run(circuit, repetitions=1, target='qpu') + + assert result == qpu_result.to_cirq_result(params=cirq.ParamResolver({})) + + +def test_service_run_batch_returns_single_defensive(): + """Cover the case where job.results() returns a single item for a batch run.""" + service = ionq.Service(remote_host="http://example.com", api_key="key") + mock_job = mock.MagicMock() + # Return a single QPUResult (not a list) + qpu_result = ionq.QPUResult(counts={1: 1}, num_qubits=1, measurement_dict={"m": [0]}) + mock_job.results.return_value = qpu_result + + with mock.patch.object(service, 'create_batch_job', return_value=mock_job): + circuit = cirq.Circuit(cirq.X(cirq.LineQubit(0))) + # run_batch expects a list of circuits + results = service.run_batch([circuit], repetitions=1, target='qpu') + + # Should wrap the single result in a list + assert len(results) == 1 + assert results[0] == qpu_result.to_cirq_result(params=cirq.ParamResolver({})) diff --git a/docs/hardware/ionq/jobs.md b/docs/hardware/ionq/jobs.md index 3e0e6a6b87d..4beca16ef4f 100644 --- a/docs/hardware/ionq/jobs.md +++ b/docs/hardware/ionq/jobs.md @@ -5,23 +5,25 @@ IonQ simulator. In this section we assume a `cirq_ionq.Service` object has been instantiated and is called `service` and `cirq` and `cirq_ionq` have been imported: + ```python import cirq import cirq_ionq as ionq service = ionq.Service() ``` + See [IonQ API Service](service.md) for how to set up the service. ## Running programs -The IonQ API is a service that allows you to send a quantum circuit as a *job* -to a scheduler server. This means that you can submit a job to the API, and +The IonQ API is a service that allows you to send a quantum circuit as a _job_ +to a scheduler server. This means that you can submit a job to the API, and then this job is held in a queue before being scheduled to run on the appropriate -hardware (QPU) or simulator. Once a job is created (but not necessarily yet run) +hardware (QPU) or simulator. Once a job is created (but not necessarily yet run) on the scheduler, the job is assigned an id and then you can query this job via the API. The job has a status on it, which describes what state the job is in -`running`, `completed`, `failed`, etc. From a users perspective, this is abstracted -mostly away in Cirq. A job can be run in either block modes, or non-blocking mode, +`running`, `completed`, `failed`, etc. From a users perspective, this is abstracted +mostly away in Cirq. A job can be run in either block modes, or non-blocking mode, as described below. Here we describe these different methods. @@ -40,31 +42,34 @@ circuit = cirq.Circuit( result = service.run(circuit=circuit, repetitions=100, target='qpu') print(result) ``` + Which results in + ``` x=0000000000000000000000000000000000000000000000000000111111111111111111111111111111111111111111111111 ``` + Looking at these results you should notice something strange. What are the odds -that the x measurements were all 0s followed by all 1s? The reason for this +that the x measurements were all 0s followed by all 1s? The reason for this sorting is that the IonQAPI only returns statistics about the results, i.e. what count of results were 0 and what count were 1 (or if you are measuring -multiple qubits the counts of the different outcome bit string outcomes). In +multiple qubits the counts of the different outcome bit string outcomes). In order to make this compatible with Cirq's notion of `cirq.Result`, these are then converted into raw results with the exactly correct number of results (in lexical order). In other words, the measurement results are not in an order corresponding to the temporal order of the measurements. When calling run, you will need to include the number of `repetitions` or shots -for the given circuit. In addition, if there is no `default_target` set on the -service, then a `target` needs to be specified. Currently the supported targets +for the given circuit. In addition, if there is no `default_target` set on the +service, then a `target` needs to be specified. Currently the supported targets are `qpu` and `simulator`. ### Via a sampler -Another method to get results from the IonQ API is to use a sampler. A sampler +Another method to get results from the IonQ API is to use a sampler. A sampler is specifically design to be a lightweight interface for obtaining results in a [pandas](https://pandas.pydata.org/) dataframe and is the interface -used by other classes in Cirq for objects that process data. Here is a +used by other classes in Cirq for objects that process data. Here is a simple example showing how to get a sampler and use it. ```python @@ -81,11 +86,11 @@ print(result) ### Via create job The above two methods, using run and the sampler, both block waiting for -results. This can be problematic when the queueing time for the service -is long. Instead, it is recommended that you use the job api directly. +results. This can be problematic when the queueing time for the service +is long. Instead, it is recommended that you use the job api directly. In this pattern, you can first create the job with the quantum circuit you wish to run, and the service immediately returns an object that has -the id of the job. This job id can be recorded, and at any time in +the id of the job. This job id can be recorded, and at any time in the future you can query for the results of this job. ```python @@ -97,21 +102,39 @@ circuit = cirq.Circuit( job = service.create_job(circuit=circuit, target='qpu', repetitions=100) print(job) ``` + which shows that the returned object is a `cirq_ionq.Job`: + ``` cirq_ionq.Job(job_id=93d111c1-0898-48b8-babe-80d182f8ad66) ``` One difference between this approach and the run and sampler methods is that the returned job object's results are more directly related to the -return data from the IonQ API. They are of types `ionq.QPUResult` or -`ionq.SimulatorResult`. If you wish to convert these into the +return data from the IonQ API. They are of types `ionq.QPUResult` or +`ionq.SimulatorResult`. If you wish to convert these into the `cirq.Result` format, you can use `to_cirq_result` on both of these. Another useful feature of working with jobs directly is that you can -directly cancel or delete jobs. In particular, the `ionq.Job` object +directly cancel or delete jobs. In particular, the `ionq.Job` object returned by `create_job` has `cancel` and `delete` methods. +### Format of values returned by `Job.results()` + +In contrast to `Service.run(...)`, which always returns a single `cirq.Result`, +the value returned by `Job.results()` depends on the type of input. For jobs +created from a _single circuit_, `job.results()` returns a **single** +`ionq.QPUResult` or `ionq.SimulatorResult`; for _batch_ jobs, it returns a +**list** of those results. An example of how you can write code that works with +beither shape is this approach: + +```python +r = job.results() +results_list = r if isinstance(r, list) else [r] +``` + +Each entry can then be converted to a `cirq.Result` via `.to_cirq_result(...)`. + ## Next steps [Get information about QPUs from IonQ calibrations](calibrations.md)