From 3e9cb5c2c54dcaf283c4a97069d9fa9d8f1fb49f Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:03:27 -0700 Subject: [PATCH 01/11] return a single results object instead of always a list --- cirq-ionq/cirq_ionq/job.py | 28 ++++++++++++++++++++-------- cirq-ionq/cirq_ionq/job_test.py | 22 +++++++++++----------- cirq-ionq/cirq_ionq/sampler.py | 11 ++++++++--- cirq-ionq/cirq_ionq/service.py | 19 +++++++++++-------- 4 files changed, 50 insertions(+), 30 deletions(-) diff --git a/cirq-ionq/cirq_ionq/job.py b/cirq-ionq/cirq_ionq/job.py index da6e3d918ff..b1b9f873a4b 100644 --- a/cirq-ionq/cirq_ionq/job.py +++ b/cirq-ionq/cirq_ionq/job.py @@ -16,7 +16,7 @@ import json import time import warnings -from typing import Dict, Optional, Sequence, TYPE_CHECKING, Union +from typing import Dict, Optional, Sequence, TYPE_CHECKING, Union, List import cirq from cirq._doc import document @@ -195,7 +195,12 @@ def results( polling_seconds: int = 1, sharpen: Optional[bool] = None, extra_query_params: Optional[dict] = None, - ) -> Union[list[results.QPUResult], list[results.SimulatorResult]]: + ) -> Union[ + results.QPUResult, + results.SimulatorResult, + List[results.QPUResult], + List[results.SimulatorResult], + ]: """Polls the IonQ api for results. Args: @@ -242,11 +247,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. @@ -267,7 +271,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): @@ -283,7 +291,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 72880485378..2a46ae0dee1 100644 --- a/cirq-ionq/cirq_ionq/job_test.py +++ b/cirq-ionq/cirq_ionq/job_test.py @@ -97,7 +97,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(): @@ -146,7 +146,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(): @@ -177,7 +177,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(): @@ -198,7 +198,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(): @@ -214,7 +214,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(): @@ -236,7 +236,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) @@ -254,7 +254,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() @@ -292,7 +292,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(): @@ -334,7 +334,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(): @@ -355,7 +355,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(): @@ -370,7 +370,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 14c0dde320f..78aa6129289 100644 --- a/cirq-ionq/cirq_ionq/sampler.py +++ b/cirq-ionq/cirq_ionq/sampler.py @@ -100,11 +100,16 @@ def run_sweep( ) for resolver in resolvers ] + # ─── collect results ─────────────────────────────────────────── if self._timeout_seconds is not None: - job_results = [job.results(timeout_seconds=self._timeout_seconds) for job in jobs] + raw_results = [j.results(timeout_seconds=self._timeout_seconds) for j in jobs] else: - job_results = [job.results() for job in jobs] - flattened_job_results = list(itertools.chain.from_iterable(job_results)) + raw_results = [j.results() for j in jobs] + + # each element of `raw_results` might be a single result or a list + flattened_job_results = [] + for r in raw_results: + flattened_job_results.extend(r if isinstance(r, list) else [r]) cirq_results = [] for result, params in zip(flattened_job_results, resolvers): if isinstance(result, results.QPUResult): diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index cd99c04bf28..58c59286198 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -124,7 +124,7 @@ def run( A `cirq.Result` for running the circuit. """ resolved_circuit = cirq.resolve_parameters(circuit, param_resolver) - job_results = self.create_job( + job_out = self.create_job( circuit=resolved_circuit, repetitions=repetitions, name=name, @@ -132,13 +132,16 @@ def run( error_mitigation=error_mitigation, 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])}'.") + + # normalise: single‑circuit jobs should deliver one result + if isinstance(job_out, list): + job_out = job_out[0] + + if isinstance(job_out, results.QPUResult): + return job_out.to_cirq_result(params=cirq.ParamResolver(param_resolver)) + if isinstance(job_out, results.SimulatorResult): + return job_out.to_cirq_result(params=cirq.ParamResolver(param_resolver), seed=seed) + raise NotImplementedError(f"Unrecognized job result type '{type(job_out)}'.") def run_batch( self, From 382be84107f46fc987578a28707e15d4c047449e Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:13:58 -0700 Subject: [PATCH 02/11] Clarify comment regarding single-circuit job results --- cirq-ionq/cirq_ionq/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index 58c59286198..d2c0861ebd9 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -133,7 +133,7 @@ def run( extra_query_params=extra_query_params, ).results(sharpen=sharpen) - # normalise: single‑circuit jobs should deliver one result + # single‑circuit jobs should deliver one result if isinstance(job_out, list): job_out = job_out[0] From 80e830fea88a7adca6722fdf96e3174ec3700a86 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:21:19 -0700 Subject: [PATCH 03/11] Clarify return type in Sampler.run_sweep documentation --- cirq-ionq/cirq_ionq/sampler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cirq-ionq/cirq_ionq/sampler.py b/cirq-ionq/cirq_ionq/sampler.py index 78aa6129289..4e693b4ee47 100644 --- a/cirq-ionq/cirq_ionq/sampler.py +++ b/cirq-ionq/cirq_ionq/sampler.py @@ -88,8 +88,8 @@ def run_sweep( repetitions: The number of times to sample. 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 scalar or list of `cirq_ionq.QPUResult` or `cirq_ionq.SimulatorResult` + depending on whether the job or jobs ran on an actual quantum processor or a simulator. """ resolvers = [r for r in cirq.to_resolvers(params)] jobs = [ @@ -100,7 +100,7 @@ def run_sweep( ) for resolver in resolvers ] - # ─── collect results ─────────────────────────────────────────── + # collect results if self._timeout_seconds is not None: raw_results = [j.results(timeout_seconds=self._timeout_seconds) for j in jobs] else: From af666067bfb8587a7de3ef09f952b64b13cfdb86 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:27:19 -0700 Subject: [PATCH 04/11] Clarify handling of single-circuit job results in Service.run method --- cirq-ionq/cirq_ionq/service.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index d2c0861ebd9..bb2cd56ccae 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -133,7 +133,10 @@ def run( extra_query_params=extra_query_params, ).results(sharpen=sharpen) - # single‑circuit jobs should deliver one result + # `create_job()` always submits a single circuit, so the API either gives us: + # - a QPUResult / SimulatorResult, or + # - a list of length‑1 (the batch logic in Job.results still wraps it in a list). + # In the latter case we unwrap it here. if isinstance(job_out, list): job_out = job_out[0] From 0625bbcd69c7cd249ce1cef7f1810238a07ea757 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:31:19 -0700 Subject: [PATCH 05/11] Refactor type annotations in job and sampler modules for consistency --- cirq-ionq/cirq_ionq/job.py | 2 +- cirq-ionq/cirq_ionq/sampler.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cirq-ionq/cirq_ionq/job.py b/cirq-ionq/cirq_ionq/job.py index b1b9f873a4b..55b207e9302 100644 --- a/cirq-ionq/cirq_ionq/job.py +++ b/cirq-ionq/cirq_ionq/job.py @@ -16,7 +16,7 @@ import json import time import warnings -from typing import Dict, Optional, Sequence, TYPE_CHECKING, Union, List +from typing import Dict, List, Optional, Sequence, TYPE_CHECKING, Union import cirq from cirq._doc import document diff --git a/cirq-ionq/cirq_ionq/sampler.py b/cirq-ionq/cirq_ionq/sampler.py index 4e693b4ee47..ef20a0b5131 100644 --- a/cirq-ionq/cirq_ionq/sampler.py +++ b/cirq-ionq/cirq_ionq/sampler.py @@ -13,8 +13,7 @@ # limitations under the License. """A `cirq.Sampler` implementation for the IonQ API.""" -import itertools -from typing import Optional, Sequence, TYPE_CHECKING +from typing import Optional, Sequence, TYPE_CHECKING, Union import cirq from cirq_ionq import results @@ -107,7 +106,7 @@ def run_sweep( raw_results = [j.results() for j in jobs] # each element of `raw_results` might be a single result or a list - flattened_job_results = [] + flattened_job_results: list[Union[results.QPUResult, results.SimulatorResult]] = [] for r in raw_results: flattened_job_results.extend(r if isinstance(r, list) else [r]) cirq_results = [] From 6364c3044fcb5a57b49ccd4e479620e68ca8bb34 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:38:22 -0700 Subject: [PATCH 06/11] Add assertion to ensure job results are iterable in Service.run method --- cirq-ionq/cirq_ionq/service.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index bb2cd56ccae..5af85cba6c1 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -16,6 +16,7 @@ import datetime import os from typing import List, Optional, Sequence +from collections.abc import Iterable import cirq from cirq_ionq import calibration, ionq_client, job, results, sampler, serializer @@ -192,6 +193,10 @@ def run_batch( error_mitigation=error_mitigation, extra_query_params=extra_query_params, ).results(sharpen=sharpen) + assert isinstance(job_results, Iterable), ( + "Expected job results to be iterable, but got type " + f"{type(job_results)}. This is a bug in the IonQ API." + ) cirq_results = [] for job_result in job_results: From 84b10f1abc3412eb204e29afeb2d7d6e9b5e7b0d Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Fri, 18 Apr 2025 14:39:42 -0700 Subject: [PATCH 07/11] Refactor import statements for improved organization in service.py --- cirq-ionq/cirq_ionq/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index 5af85cba6c1..7dbc7f10675 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -15,8 +15,8 @@ import datetime import os -from typing import List, Optional, Sequence from collections.abc import Iterable +from typing import List, Optional, Sequence import cirq from cirq_ionq import calibration, ionq_client, job, results, sampler, serializer From 809e2b631510ea27d1918b2ac8969a7836015825 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Mon, 21 Apr 2025 09:02:03 -0700 Subject: [PATCH 08/11] Add test to verify Service.run unwraps single result list --- cirq-ionq/cirq_ionq/service_test.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index 0b06c7363f1..8fe5e59798e 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -296,3 +296,27 @@ 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() From 899cb05b93610a1e23f5e7b2ea2f373486ef9755 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Mon, 21 Apr 2025 09:02:27 -0700 Subject: [PATCH 09/11] format service --- cirq-ionq/cirq_ionq/service_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index 8fe5e59798e..8f47ee07e95 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -297,6 +297,7 @@ 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) From 76247223bf20acbc9233744727de2c7895beebc9 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Thu, 24 Apr 2025 09:29:46 -0700 Subject: [PATCH 10/11] Add test for Service.run_batch to preserve input order of circuits --- cirq-ionq/cirq_ionq/service_test.py | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index 8f47ee07e95..43926309670 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import datetime import os from unittest import mock @@ -321,3 +322,52 @@ def test_service_run_unwraps_single_result_list(): 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", + "target": 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() From e76ec62ea67a861100461114ae52201eac8c0e36 Mon Sep 17 00:00:00 2001 From: Spencer Churchill Date: Thu, 24 Apr 2025 10:22:02 -0700 Subject: [PATCH 11/11] Reorder import statements in service_test.py to follow conventions --- cirq-ionq/cirq_ionq/service_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index 43926309670..2d1ce2a2575 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import datetime +import json import os from unittest import mock