Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3e9cb5c
return a single results object instead of always a list
splch Apr 18, 2025
382be84
Clarify comment regarding single-circuit job results
splch Apr 18, 2025
80e830f
Clarify return type in Sampler.run_sweep documentation
splch Apr 18, 2025
af66606
Clarify handling of single-circuit job results in Service.run method
splch Apr 18, 2025
0625bbc
Refactor type annotations in job and sampler modules for consistency
splch Apr 18, 2025
6364c30
Add assertion to ensure job results are iterable in Service.run method
splch Apr 18, 2025
84b10f1
Refactor import statements for improved organization in service.py
splch Apr 18, 2025
ad95fc5
Merge branch 'main' into scalar-or-list-results
splch Apr 21, 2025
809e2b6
Add test to verify Service.run unwraps single result list
splch Apr 21, 2025
899cb05
format service
splch Apr 21, 2025
96797b6
Merge branch 'main' into scalar-or-list-results
splch Apr 24, 2025
7624722
Add test for Service.run_batch to preserve input order of circuits
splch Apr 24, 2025
e76ec62
Reorder import statements in service_test.py to follow conventions
splch Apr 24, 2025
5474b73
Merge branch 'main' into scalar-or-list-results
splch May 2, 2025
eb3038c
Merge branch 'main' into scalar-or-list-results
splch Oct 9, 2025
33e18a1
Refactor type hints in job, sampler, and service modules for consiste…
splch Oct 9, 2025
392a4b5
Fix formatting inconsistencies in comments across job, sampler, and s…
splch Oct 9, 2025
604876f
Enhance documentation for job results and sampler methods in IonQ API…
splch Oct 9, 2025
72027cd
Clarify result shape in job results documentation for IonQ API
splch Oct 9, 2025
49506df
Merge branch 'main' into scalar-or-list-results
mhucka Oct 13, 2025
967602c
Merge branch 'main' into scalar-or-list-results
splch Oct 14, 2025
d4f5442
remove block quoting the note and use normal markdown
splch Oct 14, 2025
23ce8a9
Merge branch 'main' into scalar-or-list-results
splch Oct 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions cirq-ionq/cirq_ionq/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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:
Expand All @@ -208,16 +213,27 @@ 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.
IonQException: If unable to get the results from the API.
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:
Expand All @@ -244,11 +260,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.
Expand All @@ -269,7 +284,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):
Expand All @@ -285,7 +304,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.
Expand Down
22 changes: 11 additions & 11 deletions cirq-ionq/cirq_ionq/job_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand All @@ -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)
Expand All @@ -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()


Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand All @@ -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():
Expand Down
19 changes: 13 additions & 6 deletions cirq-ionq/cirq_ionq/sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

from __future__ import annotations

import itertools
from typing import Sequence, TYPE_CHECKING

import cirq
Expand Down Expand Up @@ -90,8 +89,11 @@ 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.
A list of `cirq.Result` objects, one per parameter resolver in
`params`, converted from IonQ results.

Notes:
This method blocks until all jobs in the sweep complete.
"""
resolvers = [r for r in cirq.to_resolvers(params)]
jobs = [
Expand All @@ -102,11 +104,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: list[results.QPUResult | results.SimulatorResult] = []
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):
Expand Down
39 changes: 29 additions & 10 deletions cirq-ionq/cirq_ionq/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import datetime
import os
from collections.abc import Iterable
from typing import Sequence

import cirq
Expand Down Expand Up @@ -142,10 +143,14 @@ def run(
extra_query_params: Specify any parameters to include in the request.

Returns:
A `cirq.Result` for running the circuit.
A `cirq.Result` for the circuit.

Notes:
The IonQ backend may return a list of length 1 for single-circuit
jobs. Cirq unwraps that to a single result for `Service.run(...)`.
"""
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,
Expand All @@ -157,13 +162,19 @@ 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])}'.")

# `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]

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,
Expand Down Expand Up @@ -215,7 +226,11 @@ def run_batch(
extra_query_params: Specify any parameters to include in the request.

Returns:
A a list of `cirq.Result` for running the circuit.
A list of `cirq.Result` objects, one per circuit.

Notes:
The output list preserves the order of the input `circuits`
argument, regardless of how the IonQ API orders per-circuit results.
"""
resolved_circuits = []
for circuit in circuits:
Expand All @@ -233,6 +248,10 @@ def run_batch(
dry_run=dry_run,
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:
Expand Down
75 changes: 75 additions & 0 deletions cirq-ionq/cirq_ionq/service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from __future__ import annotations

import datetime
import json
import os
from unittest import mock

Expand Down Expand Up @@ -296,3 +297,77 @@ 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():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a test for the correctness of the order of multiple results being returned?

"""`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()
Loading
Loading