diff --git a/cirq-ionq/cirq_ionq/ionq_client.py b/cirq-ionq/cirq_ionq/ionq_client.py index 964a191dc22..3d6f06928f8 100644 --- a/cirq-ionq/cirq_ionq/ionq_client.py +++ b/cirq-ionq/cirq_ionq/ionq_client.py @@ -102,7 +102,8 @@ def __init__( ), f'Target can only be one of {self.SUPPORTED_TARGETS} but was {default_target}.' assert max_retry_seconds >= 0, 'Negative retry not possible without time machine.' - self.url = f'{url.scheme}://{url.netloc}/{api_version}' + self.url_base = f'{url.scheme}://{url.netloc}' + self.url = f'{self.url_base}/{api_version}' self.headers = self.api_headers(api_key) self.default_target = default_target self.max_retry_seconds = max_retry_seconds @@ -221,7 +222,7 @@ def get_results( extra_query_params: Specify any parameters to include in the request. Returns: - extra_query_paramsresponse as a dict. + response as a dict. Raises: IonQNotFoundException: If job or results don't exist. @@ -252,6 +253,26 @@ def request(): return self._make_request(request, {}).json() + def get_shots(self, shots_url): + """Get job per shot output from IonQ API. + + Args: + shots_url: The shots URL as returned by the IonQ API. + + Returns: + response as a dict. + + Raises: + IonQException: For other API call failures. + """ + + def request(): + return requests.get( + urllib.parse.urljoin(self.url_base, shots_url), headers=self.headers + ) + + return self._make_request(request, {}).json() + def list_jobs( self, status: str | None = None, limit: int = 100, batch_size: int = 1000 ) -> list[dict[str, Any]]: diff --git a/cirq-ionq/cirq_ionq/ionq_client_test.py b/cirq-ionq/cirq_ionq/ionq_client_test.py index dad5b425e86..cde81591169 100644 --- a/cirq-ionq/cirq_ionq/ionq_client_test.py +++ b/cirq-ionq/cirq_ionq/ionq_client_test.py @@ -595,6 +595,23 @@ def test_ionq_client_get_job_retry(mock_get): assert mock_get.call_count == 2 +@mock.patch('requests.get') +def test_ionq_client_get_shots(mock_get): + mock_get.return_value.ok = True + mock_get.return_value.json.return_value = {'foo': 'bar'} + client = ionq.ionq_client._IonQClient(remote_host='http://example.com', api_key='to_my_heart') + client.batch_mode = False + response = client.get_shots(shots_url="v0.4/results/shots/") + assert response == {'foo': 'bar'} + + expected_headers = { + 'Authorization': 'apiKey to_my_heart', + 'Content-Type': 'application/json', + 'User-Agent': client._user_agent(), + } + mock_get.assert_called_with('http://example.com/v0.4/results/shots/', headers=expected_headers) + + @mock.patch('requests.get') def test_ionq_client_get_results(mock_get): mock_get.return_value.ok = True diff --git a/cirq-ionq/cirq_ionq/job.py b/cirq-ionq/cirq_ionq/job.py index 849f8a810d2..25414839665 100644 --- a/cirq-ionq/cirq_ionq/job.py +++ b/cirq-ionq/cirq_ionq/job.py @@ -45,30 +45,32 @@ class Job: valid. """ - TERMINAL_STATES = ('completed', 'canceled', 'failed', 'deleted') + TERMINAL_STATES = ("completed", "canceled", "failed", "deleted") document( TERMINAL_STATES, - 'States of the IonQ API job from which the job cannot transition. ' - 'Note that deleted can only exist in a return call from a delete ' - '(subsequent calls will return not found).', + "States of the IonQ API job from which the job cannot transition. " + "Note that deleted can only exist in a return call from a delete " + "(subsequent calls will return not found).", ) - NON_TERMINAL_STATES = ('ready', 'submitted', 'running') + NON_TERMINAL_STATES = ("ready", "submitted", "running") document( - NON_TERMINAL_STATES, 'States of the IonQ API job which can transition to other states.' + NON_TERMINAL_STATES, "States of the IonQ API job which can transition to other states." ) ALL_STATES = TERMINAL_STATES + NON_TERMINAL_STATES - document(ALL_STATES, 'All states that an IonQ API job can exist in.') + document(ALL_STATES, "All states that an IonQ API job can exist in.") - UNSUCCESSFUL_STATES = ('canceled', 'failed', 'deleted') + UNSUCCESSFUL_STATES = ("canceled", "failed", "deleted") document( UNSUCCESSFUL_STATES, - 'States of the IonQ API job when it was not successful and so does not have any ' - 'data associated with it beyond an id and a status.', + "States of the IonQ API job when it was not successful and so does not have any " + "data associated with it beyond an id and a status.", ) - def __init__(self, client: cirq_ionq.ionq_client._IonQClient, job_dict: dict): + def __init__( + self, client: cirq_ionq.ionq_client._IonQClient, job_dict: dict, memory: bool = False + ): """Construct an IonQJob. Users should not call this themselves. If you only know the `job_id`, use `get_job` @@ -77,13 +79,15 @@ def __init__(self, client: cirq_ionq.ionq_client._IonQClient, job_dict: dict): Args: client: The client used for calling the API. job_dict: A dict representing the response from a call to get_job on the client. + memory: Whether to attempt to retrieve memory (per shot) results for the job. """ self._client = client self._job = job_dict + self._memory = memory def _refresh_job(self): """If the last fetched job is not terminal, gets the job from the API.""" - if self._job['status'] not in self.TERMINAL_STATES: + if self._job["status"] not in self.TERMINAL_STATES: self._job = self._client.get_job(self.job_id()) def _check_if_unsuccessful(self): @@ -95,7 +99,7 @@ def job_id(self) -> str: This is the id used for identifying the job by the API. """ - return self._job['id'] + return self._job["id"] def status(self) -> str: """Gets the current status of the job. @@ -110,7 +114,7 @@ def status(self) -> str: The job status. """ self._refresh_job() - return self._job['status'] + return self._job["status"] def target(self) -> str: """Returns the target where the job is to be run, or was run. @@ -123,7 +127,7 @@ def target(self) -> str: IonQException: If unable to get the status of the job from the API. """ self._check_if_unsuccessful() - return self._job['backend'] + return self._job["backend"] def name(self) -> str: """Returns the name of the job which was supplied during job creation. @@ -135,7 +139,7 @@ def name(self) -> str: IonQException: If unable to get the status of the job from the API. """ self._check_if_unsuccessful() - return self._job['name'] + return self._job["name"] def num_qubits(self, circuit_index=None) -> int: """Returns the number of qubits for the job. @@ -145,14 +149,14 @@ def num_qubits(self, circuit_index=None) -> int: IonQException: If unable to get the status of the job from the API. """ self._check_if_unsuccessful() - if 'metadata' in self._job and circuit_index is not None: - if 'qubit_numbers' in self._job['metadata'].keys(): - qubit_numbers = json.loads(self._job['metadata']['qubit_numbers']) + if "metadata" in self._job and circuit_index is not None: + if "qubit_numbers" in self._job["metadata"].keys(): + qubit_numbers = json.loads(self._job["metadata"]["qubit_numbers"]) for index, qubit_number in enumerate(qubit_numbers): if index == circuit_index: return qubit_number - return int(self._job['stats']['qubits']) + return int(self._job["stats"]["qubits"]) def repetitions(self) -> int: """Returns the number of repetitions for the job. @@ -162,33 +166,33 @@ def repetitions(self) -> int: IonQException: If unable to get the status of the job from the API. """ self._check_if_unsuccessful() - return int(self._job['metadata']['shots']) + return int(self._job["metadata"]["shots"]) def measurement_dict(self, circuit_index=0) -> dict[str, Sequence[int]]: """Returns a dictionary of measurement keys to target qubit index.""" measurement_dict: dict[str, Sequence[int]] = {} - if 'metadata' in self._job: + if "metadata" in self._job: measurement_matadata = None - if 'measurements' in self._job['metadata'].keys(): - measurements = json.loads(self._job['metadata']['measurements']) + if "measurements" in self._job["metadata"].keys(): + measurements = json.loads(self._job["metadata"]["measurements"]) for index, measurement in enumerate(measurements): if index == circuit_index: measurement_matadata = measurement break else: - measurement_matadata = self._job['metadata'] + measurement_matadata = self._job["metadata"] if measurement_matadata is not None: - full_str = ''.join( + full_str = "".join( value for key, value in measurement_matadata.items() - if key.startswith('measurement') + if key.startswith("measurement") ) - if full_str == '': + if full_str == "": return measurement_dict for key_value in full_str.split(chr(30)): key, value = key_value.split(chr(31)) - measurement_dict[key] = [int(t) for t in value.split(',')] + measurement_dict[key] = [int(t) for t in value.split(",")] return measurement_dict @@ -243,18 +247,18 @@ def results( break time.sleep(polling_seconds) time_waited_seconds += polling_seconds - if 'warning' in self._job and 'messages' in self._job['warning']: - for warning in self._job['warning']['messages']: + if "warning" in self._job and "messages" in self._job["warning"]: + for warning in self._job["warning"]["messages"]: warnings.warn(warning) - if self.status() != 'completed': - if 'failure' in self._job and 'error' in self._job['failure']: - error = self._job['failure']['error'] - raise RuntimeError(f'Job failed. Error message: {error}') + if self.status() != "completed": + if "failure" in self._job and "error" in self._job["failure"]: + error = self._job["failure"]["error"] + raise RuntimeError(f"Job failed. Error message: {error}") if time_waited_seconds >= timeout_seconds: - raise TimeoutError(f'Job timed out after waiting {time_waited_seconds} seconds.') + raise TimeoutError(f"Job timed out after waiting {time_waited_seconds} seconds.") raise RuntimeError( - f'Job was not completed successfully. Instead had status: {self.status()}' + f"Job was not completed successfully. Instead had status: {self.status()}" ) backend_results = self._client.get_results( @@ -266,9 +270,31 @@ def results( is_batch = isinstance(some_inner_value, dict) histograms = list(backend_results.values()) if is_batch else [backend_results] + memory_results: list[list[str] | None] = [None for _ in histograms] + retrieve_memory_result = self._memory and ( + self.target().startswith("qpu") + or ( + "noise" in self._job + and "model" in self._job["noise"] + and self._job["noise"]["model"] != "ideal" + ) + ) + if retrieve_memory_result: + if len(histograms) > 1: + child_job_ids = self._job.get("child_job_ids") + if child_job_ids is None or len(child_job_ids) != len(histograms): + self._warn_memory_fallback( + "However, the job does not have the correct child job " + "ids to retrieve per shot results for a batch job." + ) + else: + memory_results = self._retrieve_child_job_shots(child_job_ids) + else: + memory_results = self._retrieve_job_shots() + # IonQ returns results in little endian, but # Cirq prefers to use big endian, so we convert. - if self.target().startswith('qpu'): + if self.target().startswith("qpu"): big_endian_results_qpu: list[results.QPUResult] = [] for circuit_index, histogram in enumerate(histograms): repetitions = self.repetitions() @@ -283,6 +309,7 @@ def results( counts=counts, num_qubits=self.num_qubits(circuit_index), measurement_dict=self.measurement_dict(circuit_index=circuit_index), + memory_results=memory_results[circuit_index], ) ) return ( @@ -303,6 +330,7 @@ def results( num_qubits=self.num_qubits(circuit_index), measurement_dict=self.measurement_dict(circuit_index=circuit_index), repetitions=self.repetitions(), + memory_results=memory_results[circuit_index], ) ) return ( @@ -311,6 +339,63 @@ def results( else big_endian_results_sim[0] ) + def _retrieve_child_job_shots(self, child_job_ids) -> list[list[str] | None]: + """Retrieve shots for child jobs. Warn that memory results will + fall back to sampled probabilities if retrieval fails. + """ + memory_results = [] + for child_job_id in child_job_ids: + try: + memory_result = self._client.get_shots( + self._client.get_job(child_job_id)["results"]["shots"]["url"] + ) + memory_results.append(memory_result) + except KeyError as ex: + self._warn_memory_fallback( + f"However, retrieving shots for child job {child_job_id} failed because " + f"the url for shots result was not found in the job response: {ex}." + ) + memory_results.append(None) + except ( + ionq_exceptions.IonQException, + ionq_exceptions.IonQNotFoundException, + TimeoutError, + ) as ex: + self._warn_memory_fallback( + "However, retrieving shots for child job " + f"{child_job_id} failed with this error: {ex}." + ) + memory_results.append(None) + return memory_results + + def _retrieve_job_shots(self) -> list[list[str] | None]: + """Retrieve shots for the job. Warn that memory results will + fall back to sampled probabilities if retrieval fails. + """ + memory_results: list[list[str] | None] = [None] + try: + memory_results = [self._client.get_shots(self._job["results"]["shots"]["url"])] + except KeyError as ex: + self._warn_memory_fallback( + f"However, retrieving shots for the job failed because the " + f"url for shots result was not found in the job response: {ex}." + ) + except ( + ionq_exceptions.IonQException, + ionq_exceptions.IonQNotFoundException, + TimeoutError, + ) as ex: + self._warn_memory_fallback(f"However, retrieving shots failed with this error: {ex}.") + return memory_results + + def _warn_memory_fallback(self, detail): + """Warn that memory results will fall back to sampled probabilities.""" + warnings.warn( + "You set the memory argument to `True`. " + f"{detail} Per shot results will be generated by randomly sampling " + "the probability distribution returned by the IonQ server." + ) + def cancel(self): """Cancel the given job. @@ -327,4 +412,4 @@ def delete(self): self._job = self._client.delete_job(job_id=self.job_id()) def __str__(self) -> str: - return f'cirq_ionq.Job(job_id={self.job_id()})' + return f"cirq_ionq.Job(job_id={self.job_id()})" diff --git a/cirq-ionq/cirq_ionq/job_test.py b/cirq-ionq/cirq_ionq/job_test.py index ae502f42f5a..384aac91d54 100644 --- a/cirq-ionq/cirq_ionq/job_test.py +++ b/cirq-ionq/cirq_ionq/job_test.py @@ -25,72 +25,72 @@ def test_job_fields(): job_dict = { - 'id': 'my_id', - 'backend': 'qpu', - 'name': 'bacon', - 'stats': {'qubits': '5'}, - 'status': 'completed', - 'metadata': {'shots': 1000, 'measurement0': f'a{chr(31)}0,1'}, + "id": "my_id", + "backend": "qpu", + "name": "bacon", + "stats": {"qubits": "5"}, + "status": "completed", + "metadata": {"shots": 1000, "measurement0": f"a{chr(31)}0,1"}, } job = ionq.Job(None, job_dict) - assert job.job_id() == 'my_id' - assert job.target() == 'qpu' - assert job.name() == 'bacon' + assert job.job_id() == "my_id" + assert job.target() == "qpu" + assert job.name() == "bacon" assert job.num_qubits() == 5 assert job.repetitions() == 1000 - assert job.measurement_dict() == {'a': [0, 1]} + assert job.measurement_dict() == {"a": [0, 1]} def test_job_fields_multiple_circuits(): job_dict = { - 'id': 'my_id', - 'backend': 'qpu', - 'name': 'bacon', - 'stats': {'qubits': '5'}, - 'status': 'completed', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps([{'measurement0': f'a{chr(31)}0,1'}]), + "id": "my_id", + "backend": "qpu", + "name": "bacon", + "stats": {"qubits": "5"}, + "status": "completed", + "metadata": { + "shots": 1000, + "measurements": json.dumps([{"measurement0": f"a{chr(31)}0,1"}]), }, } job = ionq.Job(None, job_dict) - assert job.job_id() == 'my_id' - assert job.target() == 'qpu' - assert job.name() == 'bacon' + assert job.job_id() == "my_id" + assert job.target() == "qpu" + assert job.name() == "bacon" assert job.num_qubits() == 5 assert job.repetitions() == 1000 - assert job.measurement_dict() == {'a': [0, 1]} + assert job.measurement_dict() == {"a": [0, 1]} def test_job_status_refresh(): for status in ionq.Job.NON_TERMINAL_STATES: mock_client = mock.MagicMock() - mock_client.get_job.return_value = {'id': 'my_id', 'status': 'completed'} - job = ionq.Job(mock_client, {'id': 'my_id', 'status': status}) - assert job.status() == 'completed' - mock_client.get_job.assert_called_with('my_id') + mock_client.get_job.return_value = {"id": "my_id", "status": "completed"} + job = ionq.Job(mock_client, {"id": "my_id", "status": status}) + assert job.status() == "completed" + mock_client.get_job.assert_called_with("my_id") for status in ionq.Job.TERMINAL_STATES: mock_client = mock.MagicMock() - job = ionq.Job(mock_client, {'id': 'my_id', 'status': status}) + job = ionq.Job(mock_client, {"id": "my_id", "status": status}) assert job.status() == status mock_client.get_job.assert_not_called() def test_job_str(): - job = ionq.Job(None, {'id': 'my_id'}) - assert str(job) == 'cirq_ionq.Job(job_id=my_id)' + job = ionq.Job(None, {"id": "my_id"}) + assert str(job) == "cirq_ionq.Job(job_id=my_id)" def test_job_results_qpu(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.6', '2': '0.4'} + mock_client.get_results.return_value = {"0": "0.6", "2": "0.4"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu', - 'metadata': {'shots': 1000, 'measurement0': f'a{chr(31)}0,1'}, - 'warning': {'messages': ['foo', 'bar']}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 1000, "measurement0": f"a{chr(31)}0,1"}, + "warning": {"messages": ["foo", "bar"]}, } job = ionq.Job(mock_client, job_dict) with warnings.catch_warnings(record=True) as w: @@ -98,29 +98,29 @@ def test_job_results_qpu(): assert len(w) == 2 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]}) + expected = ionq.QPUResult({0: 600, 1: 400}, 2, {"a": [0, 1]}) assert results == expected def test_batch_job_results_qpu(): mock_client = mock.MagicMock() mock_client.get_results.return_value = { - '0190070f-9691-7000-a1f6-306623179a83': {'0': '0.6', '2': '0.4'}, - '0190070f-991c-7000-8700-c4b56b30715d': {'1': 1.0}, + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "2": "0.4"}, + "0190070f-991c-7000-8700-c4b56b30715d": {"1": 1.0}, } job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps( - [{'measurement0': f'a{chr(31)}0,1'}, {'measurement0': f'a{chr(31)}0'}] + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": { + "shots": 1000, + "measurements": json.dumps( + [{"measurement0": f"a{chr(31)}0,1"}, {"measurement0": f"a{chr(31)}0"}] ), - 'qubit_numbers': json.dumps([2, 1]), + "qubit_numbers": json.dumps([2, 1]), }, - 'warning': {'messages': ['foo', 'bar']}, + "warning": {"messages": ["foo", "bar"]}, } job = ionq.Job(mock_client, job_dict) with warnings.catch_warnings(record=True) as w: @@ -128,54 +128,54 @@ def test_batch_job_results_qpu(): assert len(w) == 2 assert "foo" in str(w[0].message) assert "bar" in str(w[1].message) - expected_0 = ionq.QPUResult({0: 600, 1: 400}, 2, {'a': [0, 1]}) - expected_1 = ionq.QPUResult({1: 1000}, 1, {'a': [0]}) + expected_0 = ionq.QPUResult({0: 600, 1: 400}, 2, {"a": [0, 1]}) + expected_1 = ionq.QPUResult({1: 1000}, 1, {"a": [0]}) assert results[0] == expected_0 assert results[1] == expected_1 def test_job_results_rounding_qpu(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.0006', '2': '0.9994'} + mock_client.get_results.return_value = {"0": "0.0006", "2": "0.9994"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu', - 'metadata': {'shots': 5000, 'measurement0': f'a{chr(31)}0,1'}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5000, "measurement0": f"a{chr(31)}0,1"}, } # 5000*0.0006 ~ 2.9999 but should be interpreted as 3 job = ionq.Job(mock_client, job_dict) - expected = ionq.QPUResult({0: 3, 1: 4997}, 2, {'a': [0, 1]}) + expected = ionq.QPUResult({0: 3, 1: 4997}, 2, {"a": [0, 1]}) results = job.results() assert results == expected def test_job_results_failed(): - job_dict = {'id': 'my_id', 'status': 'failed', 'failure': {'error': 'too many qubits'}} + job_dict = {"id": "my_id", "status": "failed", "failure": {"error": "too many qubits"}} job = ionq.Job(None, job_dict) - with pytest.raises(RuntimeError, match='too many qubits'): + with pytest.raises(RuntimeError, match="too many qubits"): _ = job.results() - assert job.status() == 'failed' + assert job.status() == "failed" def test_job_results_failed_no_error_message(): - job_dict = {'id': 'my_id', 'status': 'failed', 'failure': {}} + job_dict = {"id": "my_id", "status": "failed", "failure": {}} job = ionq.Job(None, job_dict) - with pytest.raises(RuntimeError, match='failed'): + with pytest.raises(RuntimeError, match="failed"): _ = job.results() - assert job.status() == 'failed' + assert job.status() == "failed" def test_job_results_qpu_endianness(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} + mock_client.get_results.return_value = {"0": "0.6", "1": "0.4"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu', - 'metadata': {'shots': 1000}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 1000}, } job = ionq.Job(mock_client, job_dict) results = job.results() @@ -185,34 +185,34 @@ def test_job_results_qpu_endianness(): def test_batch_job_results_qpu_endianness(): mock_client = mock.MagicMock() mock_client.get_results.return_value = { - '0190070f-9691-7000-a1f6-306623179a83': {'0': '0.6', '1': '0.4'} + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "1": "0.4"} } job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps([{'measurement0': f'a{chr(31)}0,1'}]), - 'qubit_numbers': json.dumps([2]), + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": { + "shots": 1000, + "measurements": json.dumps([{"measurement0": f"a{chr(31)}0,1"}]), + "qubit_numbers": json.dumps([2]), }, } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results == 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(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} + mock_client.get_results.return_value = {"0": "0.6", "1": "0.4"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu.target', - 'metadata': {'shots': 1000}, - 'data': {'histogram': {'0': '0.6', '1': '0.4'}}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu.target", + "metadata": {"shots": 1000}, + "data": {"histogram": {"0": "0.6", "1": "0.4"}}, } job = ionq.Job(mock_client, job_dict) results = job.results() @@ -222,75 +222,75 @@ def test_job_results_qpu_target_endianness(): def test_batch_job_results_qpu_target_endianness(): mock_client = mock.MagicMock() mock_client.get_results.return_value = { - '0190070f-9691-7000-a1f6-306623179a83': {'0': '0.6', '1': '0.4'} + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "1": "0.4"} } job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'qpu.target', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps([{'measurement0': f'a{chr(31)}0,1'}]), - 'qubit_numbers': json.dumps([2]), + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu.target", + "metadata": { + "shots": 1000, + "measurements": json.dumps([{"measurement0": f"a{chr(31)}0,1"}]), + "qubit_numbers": json.dumps([2]), }, - 'data': {'histogram': {'0': '0.6', '1': '0.4'}}, + "data": {"histogram": {"0": "0.6", "1": "0.4"}}, } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results == 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) +@mock.patch("time.sleep", return_value=None) def test_job_results_poll(mock_sleep): - ready_job = {'id': 'my_id', 'status': 'ready'} + ready_job = {"id": "my_id", "status": "ready"} completed_job = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '1'}, - 'backend': 'qpu', - 'metadata': {'shots': 1000}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "1"}, + "backend": "qpu", + "metadata": {"shots": 1000}, } mock_client = mock.MagicMock() mock_client.get_job.side_effect = [ready_job, completed_job] - mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} + 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 == ionq.QPUResult({0: 600, 1: 400}, 1, measurement_dict={}) mock_sleep.assert_called_once() -@mock.patch('time.sleep', return_value=None) +@mock.patch("time.sleep", return_value=None) def test_job_results_poll_timeout(mock_sleep): - ready_job = {'id': 'my_id', 'status': 'ready'} + ready_job = {"id": "my_id", "status": "ready"} mock_client = mock.MagicMock() mock_client.get_job.return_value = ready_job job = ionq.Job(mock_client, ready_job) - with pytest.raises(TimeoutError, match='seconds'): + with pytest.raises(TimeoutError, match="seconds"): _ = job.results(timeout_seconds=1, polling_seconds=0.1) assert mock_sleep.call_count == 11 -@mock.patch('time.sleep', return_value=None) +@mock.patch("time.sleep", return_value=None) def test_job_results_poll_timeout_with_error_message(mock_sleep): - ready_job = {'id': 'my_id', 'status': 'failure', 'failure': {'error': 'too many qubits'}} + ready_job = {"id": "my_id", "status": "failure", "failure": {"error": "too many qubits"}} mock_client = mock.MagicMock() mock_client.get_job.return_value = ready_job job = ionq.Job(mock_client, ready_job) - with pytest.raises(RuntimeError, match='too many qubits'): + with pytest.raises(RuntimeError, match="too many qubits"): _ = job.results(timeout_seconds=1, polling_seconds=0.1) assert mock_sleep.call_count == 11 def test_job_results_simulator(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} + mock_client.get_results.return_value = {"0": "0.6", "1": "0.4"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '1'}, - 'backend': 'simulator', - 'metadata': {'shots': '100'}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "1"}, + "backend": "simulator", + "metadata": {"shots": "100"}, } job = ionq.Job(mock_client, job_dict) results = job.results() @@ -300,39 +300,39 @@ def test_job_results_simulator(): def test_batch_job_results_simulator(): mock_client = mock.MagicMock() mock_client.get_results.return_value = { - '0190070f-9691-7000-a1f6-306623179a83': {'0': '0.6', '2': '0.4'}, - '0190070f-991c-7000-8700-c4b56b30715d': {'1': 1.0}, + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "2": "0.4"}, + "0190070f-991c-7000-8700-c4b56b30715d": {"1": 1.0}, } job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'simulator', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps( - [{'measurement0': f'a{chr(31)}0,1'}, {'measurement0': f'a{chr(31)}0'}] + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": { + "shots": 1000, + "measurements": json.dumps( + [{"measurement0": f"a{chr(31)}0,1"}, {"measurement0": f"a{chr(31)}0"}] ), - 'qubit_numbers': json.dumps([2, 1]), + "qubit_numbers": json.dumps([2, 1]), }, } job = ionq.Job(mock_client, job_dict) results = job.results() - expected_0 = ionq.SimulatorResult({0: 0.6, 1: 0.4}, 2, {'a': [0, 1]}, repetitions=1000) - expected_1 = ionq.SimulatorResult({1: 1}, 1, {'a': [0]}, repetitions=1000) + expected_0 = ionq.SimulatorResult({0: 0.6, 1: 0.4}, 2, {"a": [0, 1]}, repetitions=1000) + expected_1 = ionq.SimulatorResult({1: 1}, 1, {"a": [0]}, repetitions=1000) assert results[0] == expected_0 assert results[1] == expected_1 def test_job_results_simulator_endianness(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '0.6', '1': '0.4'} + mock_client.get_results.return_value = {"0": "0.6", "1": "0.4"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'simulator', - 'metadata': {'shots': '100'}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": {"shots": "100"}, } job = ionq.Job(mock_client, job_dict) results = job.results() @@ -342,33 +342,33 @@ def test_job_results_simulator_endianness(): def test_batch_job_results_simulator_endianness(): mock_client = mock.MagicMock() mock_client.get_results.return_value = { - '0190070f-9691-7000-a1f6-306623179a83': {'0': '0.6', '1': '0.4'} + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "1": "0.4"} } job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '2'}, - 'backend': 'simulator', - 'metadata': { - 'shots': 1000, - 'measurements': json.dumps([{'measurement0': f'a{chr(31)}0,1'}]), - 'qubit_numbers': json.dumps([2]), + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": { + "shots": 1000, + "measurements": json.dumps([{"measurement0": f"a{chr(31)}0,1"}]), + "qubit_numbers": json.dumps([2]), }, } job = ionq.Job(mock_client, job_dict) results = job.results() - assert results == 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(): mock_client = mock.MagicMock() - mock_client.get_results.return_value = {'0': '60', '1': '40'} + mock_client.get_results.return_value = {"0": "60", "1": "40"} job_dict = { - 'id': 'my_id', - 'status': 'completed', - 'stats': {'qubits': '1'}, - 'backend': 'simulator', - 'metadata': {'shots': '100'}, + "id": "my_id", + "status": "completed", + "stats": {"qubits": "1"}, + "backend": "simulator", + "metadata": {"shots": "100"}, } job = ionq.Job(mock_client, job_dict) results = job.results(sharpen=False) @@ -376,83 +376,559 @@ def test_job_sharpen_results(): def test_job_cancel(): - ready_job = {'id': 'my_id', 'status': 'ready'} - canceled_job = {'id': 'my_id', 'status': 'canceled'} + ready_job = {"id": "my_id", "status": "ready"} + canceled_job = {"id": "my_id", "status": "canceled"} mock_client = mock.MagicMock() mock_client.cancel_job.return_value = canceled_job job = ionq.Job(mock_client, ready_job) job.cancel() - mock_client.cancel_job.assert_called_with(job_id='my_id') - assert job.status() == 'canceled' + mock_client.cancel_job.assert_called_with(job_id="my_id") + assert job.status() == "canceled" def test_job_delete(): - ready_job = {'id': 'my_id', 'status': 'ready'} - deleted_job = {'id': 'my_id', 'status': 'deleted'} + ready_job = {"id": "my_id", "status": "ready"} + deleted_job = {"id": "my_id", "status": "deleted"} mock_client = mock.MagicMock() mock_client.delete_job.return_value = deleted_job job = ionq.Job(mock_client, ready_job) job.delete() - mock_client.delete_job.assert_called_with(job_id='my_id') - assert job.status() == 'deleted' + mock_client.delete_job.assert_called_with(job_id="my_id") + assert job.status() == "deleted" def test_job_fields_unsuccessful(): job_dict = { - 'id': 'my_id', - 'backend': 'qpu', - 'name': 'bacon', - 'stats': {'qubits': '5'}, - 'status': 'deleted', - 'metadata': {'shots': 1000}, + "id": "my_id", + "backend": "qpu", + "name": "bacon", + "stats": {"qubits": "5"}, + "status": "deleted", + "metadata": {"shots": 1000}, } job = ionq.Job(None, job_dict) - with pytest.raises(ionq.IonQUnsuccessfulJobException, match='deleted'): + with pytest.raises(ionq.IonQUnsuccessfulJobException, match="deleted"): _ = job.target() - with pytest.raises(ionq.IonQUnsuccessfulJobException, match='deleted'): + with pytest.raises(ionq.IonQUnsuccessfulJobException, match="deleted"): _ = job.name() - with pytest.raises(ionq.IonQUnsuccessfulJobException, match='deleted'): + with pytest.raises(ionq.IonQUnsuccessfulJobException, match="deleted"): _ = job.num_qubits() - with pytest.raises(ionq.IonQUnsuccessfulJobException, match='deleted'): + with pytest.raises(ionq.IonQUnsuccessfulJobException, match="deleted"): _ = job.repetitions() def test_job_fields_cannot_get_status(): job_dict = { - 'id': 'my_id', - 'backend': 'qpu', - 'name': 'bacon', - 'stats': {'qubits': '5'}, - 'status': 'running', - 'metadata': {'shots': 1000}, + "id": "my_id", + "backend": "qpu", + "name": "bacon", + "stats": {"qubits": "5"}, + "status": "running", + "metadata": {"shots": 1000}, } mock_client = mock.MagicMock() - mock_client.get_job.side_effect = ionq.IonQException('bad') + mock_client.get_job.side_effect = ionq.IonQException("bad") job = ionq.Job(mock_client, job_dict) - with pytest.raises(ionq.IonQException, match='bad'): + with pytest.raises(ionq.IonQException, match="bad"): _ = job.target() - with pytest.raises(ionq.IonQException, match='bad'): + with pytest.raises(ionq.IonQException, match="bad"): _ = job.name() - with pytest.raises(ionq.IonQException, match='bad'): + with pytest.raises(ionq.IonQException, match="bad"): _ = job.num_qubits() - with pytest.raises(ionq.IonQException, match='bad'): + with pytest.raises(ionq.IonQException, match="bad"): _ = job.repetitions() def test_job_fields_update_status(): job_dict = { - 'id': 'my_id', - 'backend': 'qpu', - 'name': 'bacon', - 'stats': {'qubits': '5'}, - 'status': 'running', - 'metadata': {'shots': 1000}, + "id": "my_id", + "backend": "qpu", + "name": "bacon", + "stats": {"qubits": "5"}, + "status": "running", + "metadata": {"shots": 1000}, } mock_client = mock.MagicMock() mock_client.get_job.return_value = job_dict job = ionq.Job(mock_client, job_dict) - assert job.job_id() == 'my_id' - assert job.target() == 'qpu' - assert job.name() == 'bacon' + assert job.job_id() == "my_id" + assert job.target() == "qpu" + assert job.name() == "bacon" assert job.num_qubits() == 5 assert job.repetitions() == 1000 + + +@pytest.mark.parametrize("memory", [True, False]) +def test_memory_job_results_ideal_simulator(memory): + mock_client = mock.MagicMock() + mock_client.get_shots.return_value = [2, 1, 3, 1, 0] + mock_client.get_results.return_value = {"0": "1"} + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": { + "shots": "5", + "measurements": json.dumps([{"measurement0": f"results{chr(31)}0,1"}]), + }, + "results": {"shots": {"url": "/shots"}}, + "noise": {"model": "ideal"}, + } + job = ionq.Job(mock_client, job_dict, memory=memory) + result = job.results() + cirq_result = result.to_cirq_result() + assert cirq_result.measurements["results"].tolist() == [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0]] + mock_client.get_shots.assert_not_called() + + +@pytest.mark.parametrize("memory", [True, False]) +def test_memory_job_results_noisy_simulator(memory): + mock_client = mock.MagicMock() + mock_client.get_results.return_value = {"0": "0.6", "1": "0.4"} + mock_client.get_shots.return_value = [2, 1, 3, 1, 0] + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": { + "shots": "5", + "measurements": json.dumps([{"measurement0": f"results{chr(31)}0,1"}]), + }, + "results": {"shots": {"url": "/shots"}}, + "noise": {"model": "aria-1"}, + } + job = ionq.Job(mock_client, job_dict, memory=memory) + result = job.results() + if memory: + cirq_result = result.to_cirq_result() + expected = [[0, 1], [1, 0], [1, 1], [1, 0], [0, 0]] + mock_client.get_shots.assert_called_once_with("/shots") + else: + fake_random_state = mock.Mock() + fake_random_state.choice.return_value = [1, 0, 1, 1, 0] + with mock.patch( + "cirq_ionq.results.cirq.value.parse_random_state", return_value=fake_random_state + ) as parse_random_state: + cirq_result = result.to_cirq_result() + + expected = [[1, 0], [0, 0], [1, 0], [1, 0], [0, 0]] + parse_random_state.assert_called_once_with(None) + fake_random_state.choice.assert_called_once_with(range(2), p=(0.6, 0.4), size=5) + mock_client.get_shots.assert_not_called() + + assert cirq_result.measurements["results"].tolist() == expected + + +@pytest.mark.parametrize("backend", ["simulator", "qpu"]) +@pytest.mark.parametrize("child_job_ids", [None, ["child_1"]]) +def test_memory_batch_job_results_no_child_job_ids_falls_back(backend, child_job_ids): + mock_client = mock.MagicMock() + mock_client.get_results.return_value = { + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "1": "0.4"}, + "0190070f-991c-7000-8700-c4b56b30715d": {"0": "1.0"}, + } + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": backend, + "metadata": { + "shots": "5", + "measurements": json.dumps( + [{"measurement0": f"results{chr(31)}0,1"}, {"measurement0": f"results{chr(31)}0"}] + ), + "qubit_numbers": json.dumps([2, 1]), + }, + "noise": {"model": "aria-1"}, + } + if child_job_ids is not None: + job_dict["child_job_ids"] = child_job_ids + + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job.results() + + assert len(w) == 1 + assert "correct child job ids to retrieve per shot results for a batch job" in str(w[0].message) + assert len(result) == 2 + assert result[0]._memory_results is None + assert result[1]._memory_results is None + mock_client.get_job.assert_not_called() + mock_client.get_shots.assert_not_called() + + +def test_memory_batch_job_results_noisy_simulator(): + mock_client = mock.MagicMock() + mock_client.get_results.return_value = { + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "1": "0.4"}, + "0190070f-991c-7000-8700-c4b56b30715d": {"0": "1.0"}, + } + mock_client.get_job.side_effect = [ + {"id": "child_1", "status": "completed", "results": {"shots": {"url": "/shots/1"}}}, + {"id": "child_2", "status": "completed", "results": {"shots": {"url": "/shots/2"}}}, + ] + mock_client.get_shots.side_effect = [[2, 1, 3, 1, 0], [0, 0, 1, 1, 0]] + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "simulator", + "metadata": { + "shots": "5", + "measurements": json.dumps( + [{"measurement0": f"results{chr(31)}0,1"}, {"measurement0": f"results{chr(31)}0"}] + ), + "qubit_numbers": json.dumps([2, 1]), + }, + "noise": {"model": "aria-1"}, + "child_job_ids": ["child_1", "child_2"], + } + job = ionq.Job(mock_client, job_dict, memory=True) + result = job.results() + + assert len(result) == 2 + assert result[0]._memory_results == [2, 1, 3, 1, 0] + assert result[1]._memory_results == [0, 0, 1, 1, 0] + mock_client.get_job.assert_any_call("child_1") + mock_client.get_job.assert_any_call("child_2") + mock_client.get_shots.assert_any_call("/shots/1") + mock_client.get_shots.assert_any_call("/shots/2") + assert mock_client.get_shots.call_count == 2 + + +@pytest.mark.parametrize("memory", [True, False]) +def test_memory_job_results_qpu(memory): + mock_client = mock.MagicMock() + mock_client.get_results.return_value = {"0": "0.6", "3": "0.4"} + mock_client.get_shots.return_value = [2, 1, 3, 1, 0] + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": { + "shots": 5, + "measurements": json.dumps([{"measurement0": f"results{chr(31)}0,1"}]), + }, + "results": {"shots": {"url": "/shots"}}, + } + job = ionq.Job(mock_client, job_dict, memory=memory) + result = job.results() + cirq_result = result.to_cirq_result() + expected = ( + [[0, 1], [1, 0], [1, 1], [1, 0], [0, 0]] + if memory + else [[0, 0], [0, 0], [0, 0], [1, 1], [1, 1]] + ) + assert cirq_result.measurements["results"].tolist() == expected + if memory: + mock_client.get_shots.assert_called_once_with("/shots") + else: + mock_client.get_shots.assert_not_called() + + +def test_memory_batch_job_results_qpu(): + mock_client = mock.MagicMock() + mock_client.get_results.return_value = { + "0190070f-9691-7000-a1f6-306623179a83": {"0": "0.6", "3": "0.4"}, + "0190070f-991c-7000-8700-c4b56b30715d": {"1": "1.0"}, + } + mock_client.get_job.side_effect = [ + {"id": "child_1", "status": "completed", "results": {"shots": {"url": "/shots/1"}}}, + {"id": "child_2", "status": "completed", "results": {"shots": {"url": "/shots/2"}}}, + ] + mock_client.get_shots.side_effect = [[2, 1, 3, 1, 0], [0, 0, 1, 1, 0]] + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": { + "shots": 5, + "measurements": json.dumps( + [{"measurement0": f"results{chr(31)}0,1"}, {"measurement0": f"results{chr(31)}0"}] + ), + "qubit_numbers": json.dumps([2, 1]), + }, + "results": {"shots": {"url": "/shots"}}, + "child_job_ids": ["child_1", "child_2"], + } + job = ionq.Job(mock_client, job_dict, memory=True) + + result = job.results() + + assert len(result) == 2 + assert result[0].counts() == {0: 3, 3: 2} + assert result[1].counts() == {1: 5} + assert result[0]._memory_results == [2, 1, 3, 1, 0] + assert result[1]._memory_results == [0, 0, 1, 1, 0] + mock_client.get_job.assert_any_call("child_1") + mock_client.get_job.assert_any_call("child_2") + mock_client.get_shots.assert_any_call("/shots/1") + mock_client.get_shots.assert_any_call("/shots/2") + assert mock_client.get_shots.call_count == 2 + + +def test_retrieve_job_shots_key_error(): + """Test _retrieve_job_shots handles KeyError when shots url is missing.""" + mock_client = mock.MagicMock() + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + "results": {}, # Missing 'shots' key + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_job_shots() + + # Verify warning was raised + assert len(w) == 1 + assert "memory argument" in str(w[0].message) + assert "url for shots result was not found" in str(w[0].message) + + # Verify result is [None] + assert result == [None] + # Verify get_shots was not called + mock_client.get_shots.assert_not_called() + + +@pytest.mark.parametrize( + "exception,error_msg", + [ + (ionq.IonQException("API error"), "API error"), + (ionq.IonQNotFoundException("Job not found"), "Job not found"), + (TimeoutError("Request timed out"), "Request timed out"), + ], +) +def test_retrieve_job_shots_api_errors(exception, error_msg): + """Test _retrieve_job_shots handles various API errors.""" + mock_client = mock.MagicMock() + mock_client.get_shots.side_effect = exception + + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + "results": {"shots": {"url": "/shots"}}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_job_shots() + + # Verify warning was raised + assert len(w) == 1 + assert "memory argument" in str(w[0].message) + assert error_msg in str(w[0].message) + + # Verify result is [None] + assert result == [None] + # Verify get_shots was called + mock_client.get_shots.assert_called_once_with("/shots") + + +def test_retrieve_job_shots_success(): + """Test _retrieve_job_shots successfully retrieves shots.""" + mock_client = mock.MagicMock() + mock_shots = [2, 1, 3, 1, 0] + mock_client.get_shots.return_value = mock_shots + + job_dict = { + "id": "my_id", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + "results": {"shots": {"url": "/shots"}}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_job_shots() + + # Verify no warning was raised + assert len(w) == 0 + + # Verify result contains shots + assert result == [mock_shots] + # Verify get_shots was called + mock_client.get_shots.assert_called_once_with("/shots") + + +def test_retrieve_child_job_shots_key_error(): + """Test _retrieve_child_job_shots handles KeyError when shots url is missing.""" + mock_client = mock.MagicMock() + # First child job has missing shots URL + mock_client.get_job.return_value = { + "id": "child_1", + "status": "completed", + "results": {}, # Missing 'shots' key + } + + job_dict = { + "id": "batch_job", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_child_job_shots(["child_1"]) + + # Verify warning was raised + assert len(w) == 1 + assert "memory argument" in str(w[0].message) + assert "url for shots result was not found" in str(w[0].message) + assert "child_1" in str(w[0].message) + + # Verify result is [None] for failed child + assert result == [None] + # Verify get_shots was not called + mock_client.get_shots.assert_not_called() + + +@pytest.mark.parametrize( + "exception,error_msg", + [ + (ionq.IonQException("API error"), "API error"), + (ionq.IonQNotFoundException("Job not found"), "Job not found"), + (TimeoutError("Request timed out"), "Request timed out"), + ], +) +def test_retrieve_child_job_shots_api_errors(exception, error_msg): + """Test _retrieve_child_job_shots handles various API errors for child jobs.""" + mock_client = mock.MagicMock() + mock_client.get_job.return_value = { + "id": "child_1", + "status": "completed", + "results": {"shots": {"url": "/shots"}}, + } + mock_client.get_shots.side_effect = exception + + job_dict = { + "id": "batch_job", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_child_job_shots(["child_1"]) + + # Verify warning was raised + assert len(w) == 1 + assert "memory argument" in str(w[0].message) + assert error_msg in str(w[0].message) + assert "child_1" in str(w[0].message) + + # Verify result is [None] for failed child + assert result == [None] + # Verify get_shots was called + mock_client.get_shots.assert_called_once_with("/shots") + + +def test_retrieve_child_job_shots_success(): + """Test _retrieve_child_job_shots successfully retrieves shots for multiple child jobs.""" + mock_client = mock.MagicMock() + mock_shots_1 = [2, 1, 3, 1, 0] + mock_shots_2 = [0, 1, 0, 1, 1] + + def get_job_side_effect(job_id): + if job_id == "child_1": + return { + "id": "child_1", + "status": "completed", + "results": {"shots": {"url": "/shots/child_1"}}, + } + else: + return { + "id": "child_2", + "status": "completed", + "results": {"shots": {"url": "/shots/child_2"}}, + } + + mock_client.get_job.side_effect = get_job_side_effect + mock_client.get_shots.side_effect = [mock_shots_1, mock_shots_2] + + job_dict = { + "id": "batch_job", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_child_job_shots(["child_1", "child_2"]) + + # Verify no warning was raised + assert len(w) == 0 + + # Verify results contain shots for both children + assert result == [mock_shots_1, mock_shots_2] + # Verify get_shots was called twice + assert mock_client.get_shots.call_count == 2 + + +def test_retrieve_child_job_shots_partial_failure(): + """Test _retrieve_child_job_shots handles partial failures across child jobs.""" + mock_client = mock.MagicMock() + mock_shots_1 = [2, 1, 3, 1, 0] + + def get_job_side_effect(job_id): + if job_id == "child_1": + return { + "id": "child_1", + "status": "completed", + "results": {"shots": {"url": "/shots/child_1"}}, + } + else: + # child_2 is missing shots URL + return {"id": "child_2", "status": "completed", "results": {}} + + mock_client.get_job.side_effect = get_job_side_effect + mock_client.get_shots.return_value = mock_shots_1 + + job_dict = { + "id": "batch_job", + "status": "completed", + "stats": {"qubits": "2"}, + "backend": "qpu", + "metadata": {"shots": 5}, + } + job = ionq.Job(mock_client, job_dict, memory=True) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = job._retrieve_child_job_shots(["child_1", "child_2"]) + + # Verify one warning was raised for child_2 + assert len(w) == 1 + assert "memory argument" in str(w[0].message) + assert "child_2" in str(w[0].message) + + # Verify results: child_1 succeeds, child_2 fails + assert result == [mock_shots_1, None] + # Verify get_shots was called once (for child_1 only) + mock_client.get_shots.assert_called_once_with("/shots/child_1") diff --git a/cirq-ionq/cirq_ionq/results.py b/cirq-ionq/cirq_ionq/results.py index 275b4c67b5d..799a99e1fc7 100644 --- a/cirq-ionq/cirq_ionq/results.py +++ b/cirq-ionq/cirq_ionq/results.py @@ -24,11 +24,20 @@ import cirq +def _memory_bits_for_targets(value: int, num_qubits: int, targets: Sequence[int]) -> list[int]: + shot_bits = list(cirq.big_endian_int_to_bits(value, bit_count=num_qubits))[::-1] + return [shot_bits[target] for target in targets] + + class QPUResult: """The results of running on an IonQ QPU.""" def __init__( - self, counts: dict[int, int], num_qubits: int, measurement_dict: dict[str, Sequence[int]] + self, + counts: dict[int, int], + num_qubits: int, + measurement_dict: dict[str, Sequence[int]], + memory_results: list[str] | None = None, ): # We require a consistent ordering, and here we use bitvector as such. # OrderedDict can be removed in python 3.7, where it is part of the contract. @@ -36,6 +45,7 @@ def __init__( self._num_qubits = num_qubits self._measurement_dict = measurement_dict self._repetitions = sum(self._counts.values()) + self._memory_results = memory_results def num_qubits(self) -> int: """Returns the number of qubits the circuit was run on.""" @@ -64,8 +74,8 @@ def ordered_results(self, key: str | None = None) -> list[int]: if key is not None and not key in self._measurement_dict: raise ValueError( - f'Measurement key {key} is not a key for a measurement gate in the' - 'circuit that produced these results.' + f"Measurement key {key} is not a key for a measurement gate in the" + "circuit that produced these results." ) targets = self._measurement_dict[key] if key is not None else range(self.num_qubits()) result: list[int] = [] @@ -99,8 +109,8 @@ def counts(self, key: str | None = None) -> Counter[int]: return collections.Counter(self._counts) if not key in self._measurement_dict: raise ValueError( - f'Measurement key {key} is not a key for a measurement gate in the' - 'circuit that produced these results.' + f"Measurement key {key} is not a key for a measurement gate in the" + "circuit that produced these results." ) result: Counter[int] = collections.Counter() result.update(self.ordered_results(key)) @@ -132,21 +142,32 @@ def to_cirq_result(self, params: cirq.ParamResolver | None = None) -> cirq.Resul """ if len(self.measurement_dict()) == 0: raise ValueError( - 'Can convert to cirq results only if the circuit had measurement gates ' - 'with measurement keys.' + "Can convert to cirq results only if the circuit had measurement gates " + "with measurement keys." ) + measurements = {} - for key, targets in self.measurement_dict().items(): - qpu_results = self.ordered_results(key) - measurements[key] = np.array( - [cirq.big_endian_int_to_bits(x, bit_count=len(targets)) for x in qpu_results] - ) + if self._memory_results is not None: + for key, targets in self.measurement_dict().items(): + bits = [ + _memory_bits_for_targets(int(value), self.num_qubits(), targets) + for value in self._memory_results + ] + measurements[key] = np.array(bits) + else: + for key, targets in self.measurement_dict().items(): + qpu_results = self.ordered_results(key) + measurements[key] = np.array( + [cirq.big_endian_int_to_bits(x, bit_count=len(targets)) for x in qpu_results] + ) + return cirq.ResultDict(params=params or cirq.ParamResolver({}), measurements=measurements) def __eq__(self, other): if not isinstance(other, QPUResult): return NotImplemented return ( + # ignoring self._memory_results self._counts == other._counts and self._num_qubits == other._num_qubits and self._measurement_dict == other._measurement_dict @@ -170,11 +191,13 @@ def __init__( num_qubits: int, measurement_dict: dict[str, Sequence[int]], repetitions: int, + memory_results: list[str] | None = None, ): self._probabilities = probabilities self._num_qubits = num_qubits self._measurement_dict = measurement_dict self._repetitions = repetitions + self._memory_results = memory_results def num_qubits(self) -> int: """Returns the number of qubits the circuit was run on.""" @@ -212,8 +235,8 @@ def probabilities(self, key: str | None = None) -> dict[int, float]: return self._probabilities if not key in self._measurement_dict: raise ValueError( - f'Measurement key {key} is not a key for a measurement gate in the' - 'circuit that produced these results.' + f"Measurement key {key} is not a key for a measurement gate in the" + "circuit that produced these results." ) targets = self._measurement_dict[key] result: dict[int, float] = {} @@ -262,35 +285,49 @@ def to_cirq_result( """ if len(self.measurement_dict()) == 0: raise ValueError( - 'Can convert to cirq results only if the circuit had measurement gates ' - 'with measurement keys.' + "Can convert to cirq results only if the circuit had measurement gates " + "with measurement keys." ) - rand = cirq.value.parse_random_state(seed) - measurements = {} - values, weights = zip(*self.probabilities().items()) - # normalize weights to sum to 1 if within tolerance because - # IonQ's pauliexp gates results are not extremely precise - total = sum(weights) - if np.isclose(total, 1.0, rtol=0, atol=1e-5): - weights = tuple(w / total for w in weights) - - indices = rand.choice( - range(len(values)), p=weights, size=override_repetitions or self.repetitions() - ) - rand_values = np.array(values)[indices] - for key, targets in self.measurement_dict().items(): - bits = [ - [(value >> (self.num_qubits() - target - 1)) & 1 for target in targets] - for value in rand_values - ] - measurements[key] = np.array(bits) + measurements = {} + if self._memory_results is not None: + shots = ( + self._memory_results[:override_repetitions] + if override_repetitions + else self._memory_results + ) + for key, targets in self.measurement_dict().items(): + bits = [ + _memory_bits_for_targets(int(value), self.num_qubits(), targets) + for value in shots + ] + measurements[key] = np.array(bits) + else: + rand = cirq.value.parse_random_state(seed) + values, weights = zip(*list(self.probabilities().items())) + # normalize weights to sum to 1 if within tolerance because + # IonQ's pauliexp gates results are not extremely precise + total = sum(weights) + if np.isclose(total, 1.0, rtol=0, atol=1e-5): + weights = tuple(w / total for w in weights) + + indices = rand.choice( + range(len(values)), p=weights, size=override_repetitions or self.repetitions() + ) + rand_values = np.array(values)[indices] + for key, targets in self.measurement_dict().items(): + bits = [ + [(value >> (self.num_qubits() - target - 1)) & 1 for target in targets] + for value in rand_values + ] + measurements[key] = np.array(bits) return cirq.ResultDict(params=params or cirq.ParamResolver({}), measurements=measurements) def __eq__(self, other): if not isinstance(other, SimulatorResult): return NotImplemented return ( + # ignoring self._memory_results self._probabilities == other._probabilities and self._num_qubits == other._num_qubits and self._measurement_dict == other._measurement_dict @@ -305,6 +342,6 @@ def _pretty_str_dict(value: dict, bit_count: int) -> str: """Pretty prints a dict, converting int dict values to bit strings.""" strs = [] for k, v in value.items(): - bits = ''.join(str(b) for b in cirq.big_endian_int_to_bits(k, bit_count=bit_count)) - strs.append(f'{bits}: {v}') - return '\n'.join(strs) + bits = "".join(str(b) for b in cirq.big_endian_int_to_bits(k, bit_count=bit_count)) + strs.append(f"{bits}: {v}") + return "\n".join(strs) diff --git a/cirq-ionq/cirq_ionq/results_test.py b/cirq-ionq/cirq_ionq/results_test.py index 176764ae21c..3b6de839466 100644 --- a/cirq-ionq/cirq_ionq/results_test.py +++ b/cirq-ionq/cirq_ionq/results_test.py @@ -22,299 +22,341 @@ def test_qpu_result_fields(): - result = ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'a': [0]}) + result = ionq.QPUResult( + {0: 10, 1: 10}, num_qubits=1, measurement_dict={"a": [0]}, memory_results=[1, 2, 3] + ) assert result.counts() == {0: 10, 1: 10} assert result.repetitions() == 20 assert result.num_qubits() == 1 - assert result.measurement_dict() == {'a': [0]} + assert result.measurement_dict() == {"a": [0]} + assert result._memory_results == [1, 2, 3] def test_qpu_result_str(): result = ionq.QPUResult({0: 10, 1: 10}, num_qubits=2, measurement_dict={}) - assert str(result) == '00: 10\n01: 10' + assert str(result) == "00: 10\n01: 10" def test_qpu_result_eq(): equals_tester = cirq.testing.EqualsTester() equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'a': [0]}), - ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'a': [0]}), + ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={"a": [0]}), + ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={"a": [0]}), ) equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 20}, num_qubits=1, measurement_dict={'a': [0]}) + ionq.QPUResult({0: 10, 1: 20}, num_qubits=1, measurement_dict={"a": [0]}) ) equals_tester.add_equality_group( - ionq.QPUResult({0: 15, 1: 15}, num_qubits=1, measurement_dict={'a': [0]}) + ionq.QPUResult({0: 15, 1: 15}, num_qubits=1, measurement_dict={"a": [0]}) ) equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 10}, num_qubits=2, measurement_dict={'a': [0]}) + ionq.QPUResult({0: 10, 1: 10}, num_qubits=2, measurement_dict={"a": [0]}) ) equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'b': [0]}) + ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={"b": [0]}) ) equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'a': [1]}) + ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={"a": [1]}) ) equals_tester.add_equality_group( - ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={'a': [0, 1]}) + ionq.QPUResult({0: 10, 1: 10}, num_qubits=1, measurement_dict={"a": [0, 1]}) ) def test_qpu_result_measurement_key(): - result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [0]}) + result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [0]}) assert result.counts() == {0b00: 10, 0b01: 20} - result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [0]}) - assert result.counts('a') == {0b0: 30} - result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [1]}) - assert result.counts('a') == {0b0: 10, 0b1: 20} - result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [0, 1]}) - assert result.counts('a') == {0b00: 10, 0b01: 20} - result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [1, 0]}) - assert result.counts('a') == {0b00: 10, 0b10: 20} - result = ionq.QPUResult({0b000: 10, 0b111: 20}, num_qubits=3, measurement_dict={'a': [2]}) - assert result.counts('a') == {0b0: 10, 0b1: 20} - result = ionq.QPUResult({0b000: 10, 0b100: 20}, num_qubits=3, measurement_dict={'a': [1, 2]}) - assert result.counts('a') == {0b00: 30} + result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [0]}) + assert result.counts("a") == {0b0: 30} + result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [1]}) + assert result.counts("a") == {0b0: 10, 0b1: 20} + result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [0, 1]}) + assert result.counts("a") == {0b00: 10, 0b01: 20} + result = ionq.QPUResult({0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [1, 0]}) + assert result.counts("a") == {0b00: 10, 0b10: 20} + result = ionq.QPUResult({0b000: 10, 0b111: 20}, num_qubits=3, measurement_dict={"a": [2]}) + assert result.counts("a") == {0b0: 10, 0b1: 20} + result = ionq.QPUResult({0b000: 10, 0b100: 20}, num_qubits=3, measurement_dict={"a": [1, 2]}) + assert result.counts("a") == {0b00: 30} def test_qpu_result_measurement_multiple_key(): result = ionq.QPUResult( - {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [0], 'b': [1]} + {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [0], "b": [1]} ) - assert result.counts('a') == {0b0: 30} - assert result.counts('b') == {0b0: 10, 0b1: 20} + assert result.counts("a") == {0b0: 30} + assert result.counts("b") == {0b0: 10, 0b1: 20} result = ionq.QPUResult( - {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [1], 'b': [0]} + {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [1], "b": [0]} ) - assert result.counts('a') == {0b0: 10, 0b1: 20} - assert result.counts('b') == {0b0: 30} + assert result.counts("a") == {0b0: 10, 0b1: 20} + assert result.counts("b") == {0b0: 30} def test_qpu_result_bad_measurement_key(): result = ionq.QPUResult( - {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={'a': [0], 'b': [1]} + {0b00: 10, 0b01: 20}, num_qubits=2, measurement_dict={"a": [0], "b": [1]} ) - with pytest.raises(ValueError, match='bad'): - result.counts('bad') + with pytest.raises(ValueError, match="bad"): + result.counts("bad") def test_qpu_result_to_cirq_result(): - result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={'x': [0, 1]}) + result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={"x": [0, 1]}) assert result.to_cirq_result() == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0, 0], [0, 1], [0, 1]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 0], [0, 1], [0, 1]])} ) - params = cirq.ParamResolver({'a': 0.1}) + params = cirq.ParamResolver({"a": 0.1}) assert result.to_cirq_result(params) == cirq.ResultDict( - params=params, measurements={'x': np.array([[0, 0], [0, 1], [0, 1]])} + params=params, measurements={"x": np.array([[0, 0], [0, 1], [0, 1]])} ) - result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={'x': [0]}) + result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={"x": [0]}) assert result.to_cirq_result() == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0], [0], [0]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0], [0], [0]])} ) - result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={'x': [1]}) + result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={"x": [1]}) assert result.to_cirq_result() == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0], [1], [1]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0], [1], [1]])} ) # cirq.Result only compares pandas data frame, so possible to have supplied an list of # list instead of a numpy multidimensional array. Check this here. - assert type(result.to_cirq_result().measurements['x']) == np.ndarray + assert type(result.to_cirq_result().measurements["x"]) == np.ndarray # Results bitstreams need to be consistent betwween measurement keys # Ordering is by bitvector, so 0b01 0b01 0b10 should be the ordering for all measurement dicts. result = ionq.QPUResult( - {0b10: 1, 0b01: 2}, num_qubits=2, measurement_dict={'x': [0, 1], 'y': [0], 'z': [1]} + {0b10: 1, 0b01: 2}, num_qubits=2, measurement_dict={"x": [0, 1], "y": [0], "z": [1]} ) assert result.to_cirq_result() == cirq.ResultDict( params=cirq.ParamResolver({}), measurements={ - 'x': np.array([[0, 1], [0, 1], [1, 0]]), - 'y': np.array([[0], [0], [1]]), - 'z': np.array([[1], [1], [0]]), + "x": np.array([[0, 1], [0, 1], [1, 0]]), + "y": np.array([[0], [0], [1]]), + "z": np.array([[1], [1], [0]]), }, ) def test_qpu_result_to_cirq_result_multiple_keys(): result = ionq.QPUResult( - {0b000: 2, 0b101: 3}, num_qubits=3, measurement_dict={'x': [1], 'y': [2, 0]} + {0b000: 2, 0b101: 3}, num_qubits=3, measurement_dict={"x": [1], "y": [2, 0]} ) assert result.to_cirq_result() == cirq.ResultDict( params=cirq.ParamResolver({}), measurements={ - 'x': np.array([[0], [0], [0], [0], [0]]), - 'y': np.array([[0, 0], [0, 0], [1, 1], [1, 1], [1, 1]]), + "x": np.array([[0], [0], [0], [0], [0]]), + "y": np.array([[0, 0], [0, 0], [1, 1], [1, 1], [1, 1]]), }, ) +def test_qpu_result_to_cirq_result_shotwise_uses_target_indices(): + result = ionq.QPUResult( + {0b10: 1}, num_qubits=2, measurement_dict={"b": [1]}, memory_results=[0b10] + ) + assert result.to_cirq_result() == cirq.ResultDict( + params=cirq.ParamResolver({}), measurements={"b": np.array([[1]])} + ) + + def test_qpu_result_to_cirq_result_no_keys(): result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={}) - with pytest.raises(ValueError, match='cirq results'): + with pytest.raises(ValueError, match="cirq results"): _ = result.to_cirq_result() def test_ordered_results_invalid_key(): - result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={'x': [1]}) - with pytest.raises(ValueError, match='is not a key for'): - _ = result.ordered_results('y') + result = ionq.QPUResult({0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={"x": [1]}) + with pytest.raises(ValueError, match="is not a key for"): + _ = result.ordered_results("y") def test_simulator_result_fields(): result = ionq.SimulatorResult( - {0: 0.4, 1: 0.6}, num_qubits=1, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.4, 1: 0.6}, + num_qubits=1, + measurement_dict={"a": [0]}, + repetitions=100, + memory_results=[1, 2, 3], ) assert result.probabilities() == {0: 0.4, 1: 0.6} assert result.num_qubits() == 1 - assert result.measurement_dict() == {'a': [0]} + assert result.measurement_dict() == {"a": [0]} assert result.repetitions() == 100 + assert result._memory_results == [1, 2, 3] def test_simulator_result_str(): result = ionq.SimulatorResult( - {0: 0.4, 1: 0.6}, num_qubits=2, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.4, 1: 0.6}, num_qubits=2, measurement_dict={"a": [0]}, repetitions=100 ) - assert str(result) == '00: 0.4\n01: 0.6' + assert str(result) == "00: 0.4\n01: 0.6" def test_simulator_result_eq(): equals_tester = cirq.testing.EqualsTester() equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"a": [0]}, repetitions=100 ), ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"a": [0]}, repetitions=100 ), ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.4, 1: 0.6}, num_qubits=1, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.4, 1: 0.6}, num_qubits=1, measurement_dict={"a": [0]}, repetitions=100 ) ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=2, measurement_dict={'a': [0]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=2, measurement_dict={"a": [0]}, repetitions=100 ) ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'b': [0]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"b": [0]}, repetitions=100 ) ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'a': [1]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"a": [1]}, repetitions=100 ) ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'a': [0, 1]}, repetitions=100 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"a": [0, 1]}, repetitions=100 ) ) equals_tester.add_equality_group( ionq.SimulatorResult( - {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={'a': [0, 1]}, repetitions=10 + {0: 0.5, 1: 0.5}, num_qubits=1, measurement_dict={"a": [0, 1]}, repetitions=10 ) ) def test_simulator_result_measurement_key(): result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [0]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [0]}, repetitions=100 ) assert result.probabilities() == {0b00: 0.2, 0b01: 0.8} result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [0]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [0]}, repetitions=100 ) - assert result.probabilities('a') == {0b0: 1.0} + assert result.probabilities("a") == {0b0: 1.0} result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [1]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [1]}, repetitions=100 ) - assert result.probabilities('a') == {0b0: 0.2, 0b1: 0.8} + assert result.probabilities("a") == {0b0: 0.2, 0b1: 0.8} result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [0, 1]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [0, 1]}, repetitions=100 ) - assert result.probabilities('a') == {0b00: 0.2, 0b01: 0.8} + assert result.probabilities("a") == {0b00: 0.2, 0b01: 0.8} result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [1, 0]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [1, 0]}, repetitions=100 ) - assert result.probabilities('a') == {0b00: 0.2, 0b10: 0.8} + assert result.probabilities("a") == {0b00: 0.2, 0b10: 0.8} result = ionq.SimulatorResult( - {0b000: 0.2, 0b111: 0.8}, num_qubits=3, measurement_dict={'a': [2]}, repetitions=100 + {0b000: 0.2, 0b111: 0.8}, num_qubits=3, measurement_dict={"a": [2]}, repetitions=100 ) - assert result.probabilities('a') == {0b0: 0.2, 0b1: 0.8} + assert result.probabilities("a") == {0b0: 0.2, 0b1: 0.8} result = ionq.SimulatorResult( - {0b000: 0.2, 0b100: 0.8}, num_qubits=3, measurement_dict={'a': [1, 2]}, repetitions=100 + {0b000: 0.2, 0b100: 0.8}, num_qubits=3, measurement_dict={"a": [1, 2]}, repetitions=100 ) - assert result.probabilities('a') == {0b00: 1.0} + assert result.probabilities("a") == {0b00: 1.0} def test_simulator_result_measurement_multiple_key(): result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [0], 'b': [1]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [0], "b": [1]}, repetitions=100 ) - assert result.probabilities('a') == {0b0: 1.0} - assert result.probabilities('b') == {0b0: 0.2, 0b1: 0.8} + assert result.probabilities("a") == {0b0: 1.0} + assert result.probabilities("b") == {0b0: 0.2, 0b1: 0.8} result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [1], 'b': [0]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [1], "b": [0]}, repetitions=100 ) - assert result.probabilities('a') == {0b0: 0.2, 0b1: 0.8} - assert result.probabilities('b') == {0b0: 1.0} + assert result.probabilities("a") == {0b0: 0.2, 0b1: 0.8} + assert result.probabilities("b") == {0b0: 1.0} def test_simulator_result_bad_measurement_key(): result = ionq.SimulatorResult( - {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={'a': [0], 'b': [1]}, repetitions=100 + {0b00: 0.2, 0b01: 0.8}, num_qubits=2, measurement_dict={"a": [0], "b": [1]}, repetitions=100 ) - with pytest.raises(ValueError, match='bad'): - result.probabilities('bad') + with pytest.raises(ValueError, match="bad"): + result.probabilities("bad") def test_simulator_result_to_cirq_result(): result = ionq.SimulatorResult( - {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={'x': [0, 1]}, repetitions=3 + {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={"x": [0, 1]}, repetitions=3 ) assert result.to_cirq_result(seed=2) == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0, 1], [0, 0], [0, 1]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 1], [0, 0], [0, 1]])} ) assert result.to_cirq_result(seed=3) == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0, 1], [0, 1], [0, 1]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 1], [0, 1], [0, 1]])} ) - params = cirq.ParamResolver({'a': 0.1}) + params = cirq.ParamResolver({"a": 0.1}) assert result.to_cirq_result(seed=3, params=params) == cirq.ResultDict( - params=params, measurements={'x': np.array([[0, 1], [0, 1], [0, 1]])} + params=params, measurements={"x": np.array([[0, 1], [0, 1], [0, 1]])} ) assert result.to_cirq_result(seed=2, override_repetitions=2) == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0, 1], [0, 0]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 1], [0, 0]])} ) result = ionq.SimulatorResult( - {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={'x': [0]}, repetitions=3 + {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={"x": [0]}, repetitions=3 ) assert result.to_cirq_result(seed=2) == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[0], [0], [0]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[0], [0], [0]])} ) result = ionq.SimulatorResult( - {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={'x': [1]}, repetitions=3 + {0b00: 0.25, 0b01: 0.75}, num_qubits=2, measurement_dict={"x": [1]}, repetitions=3 ) assert result.to_cirq_result(seed=2) == cirq.ResultDict( - params=cirq.ParamResolver({}), measurements={'x': np.array([[1], [0], [1]])} + params=cirq.ParamResolver({}), measurements={"x": np.array([[1], [0], [1]])} ) # cirq.Result only compares pandas data frame, so possible to have supplied an list of # list instead of a numpy multidimensional array. Check this here. - assert type(result.to_cirq_result().measurements['x']) == np.ndarray + assert type(result.to_cirq_result().measurements["x"]) == np.ndarray + + # when memory_results is provided, actual shot data is used instead of sampling. + result = ionq.SimulatorResult( + {0b00: 0.5, 0b11: 0.5}, + num_qubits=2, + measurement_dict={"x": [0, 1]}, + repetitions=3, + memory_results=["2", "0", "2"], + ) + assert result.to_cirq_result() == cirq.ResultDict( + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 1], [0, 0], [0, 1]])} + ) + # override_repetitions slices the memory results when memory is present. + assert result.to_cirq_result(override_repetitions=2) == cirq.ResultDict( + params=cirq.ParamResolver({}), measurements={"x": np.array([[0, 1], [0, 0]])} + ) def test_simulator_result_to_cirq_result_multiple_keys(): result = ionq.SimulatorResult( {0b000: 0.25, 0b011: 0.75}, num_qubits=3, - measurement_dict={'x': [1], 'y': [2, 0]}, + measurement_dict={"x": [1], "y": [2, 0]}, repetitions=3, ) assert result.to_cirq_result(seed=2) == cirq.ResultDict( params=cirq.ParamResolver({}), - measurements={'x': np.array([[1], [0], [1]]), 'y': np.array([[1, 0], [0, 0], [1, 0]])}, + measurements={"x": np.array([[1], [0], [1]]), "y": np.array([[1, 0], [0, 0], [1, 0]])}, + ) + + +def test_simulator_result_to_cirq_result_shotwise_uses_target_indices(): + result = ionq.SimulatorResult( + {0b10: 1.0}, num_qubits=2, measurement_dict={"b": [1]}, repetitions=1, memory_results=[0b10] + ) + assert result.to_cirq_result() == cirq.ResultDict( + params=cirq.ParamResolver({}), measurements={"b": np.array([[1]])} ) @@ -322,5 +364,5 @@ def test_simulator_result_to_cirq_result_no_keys(): result = ionq.SimulatorResult( {0b00: 1, 0b01: 2}, num_qubits=2, measurement_dict={}, repetitions=3 ) - with pytest.raises(ValueError, match='cirq results'): + with pytest.raises(ValueError, match="cirq results"): _ = result.to_cirq_result() diff --git a/cirq-ionq/cirq_ionq/service.py b/cirq-ionq/cirq_ionq/service.py index 712a77ccec7..fb5f4a39ea1 100644 --- a/cirq-ionq/cirq_ionq/service.py +++ b/cirq-ionq/cirq_ionq/service.py @@ -105,6 +105,7 @@ def run( metadata: dict | None = None, dry_run: bool = False, sharpen: bool | None = None, + memory: bool = False, extra_query_params: dict | None = None, ) -> cirq.Result: """Run the given circuit on the IonQ API. @@ -134,6 +135,9 @@ def run( If True, apply majority vote mitigation; if False, apply average mitigation. See: `Debiasing and Sharpening ` + memory: A boolean that determines whether to retrieve per shot results for the job + from IonQ servers, default is False. When False, shots will be generated locally + by sampling the retrieved probability distribution. noise (dict): {"model": str (required), "seed": int (optional)}. Defaults to None. Available noise models: ideal, aria-1, aria-2, forte-1, forte-enterprise-1 dry_run: If True, the job will be submitted by the API client but not processed @@ -155,6 +159,7 @@ def run( noise=noise, metadata=metadata, dry_run=dry_run, + memory=memory, extra_query_params=extra_query_params, ).results(sharpen=sharpen) result = job_results[0] if isinstance(job_results, list) else job_results @@ -178,6 +183,7 @@ def run_batch( metadata: dict | None = None, dry_run: bool = False, sharpen: bool | None = None, + memory: bool = False, extra_query_params: dict | None = None, ) -> list[cirq.Result]: """Run the given circuits on the IonQ API. @@ -211,6 +217,9 @@ def run_batch( dry_run: If True, the job will be submitted by the API client but not processed remotely. Useful for obtaining cost estimates. Defaults to False. metadata (dict): optional metadata to attach to the job. Defaults to None. + memory: A boolean that determines whether to retrieve per shot results for the job + from IonQ servers, default is False. When False, shots will be generated locally + by sampling the retrieved probability distribution. extra_query_params: Specify any parameters to include in the request. Returns: @@ -230,6 +239,7 @@ def run_batch( noise=noise, metadata=metadata, dry_run=dry_run, + memory=memory, extra_query_params=extra_query_params, ).results(sharpen=sharpen) @@ -274,6 +284,7 @@ def create_job( noise: dict | None = None, metadata: dict | None = None, dry_run: bool = False, + memory: bool = False, extra_query_params: dict | None = None, ) -> job.Job: """Create a new job to run the given circuit. @@ -303,6 +314,9 @@ def create_job( dry_run: If True, the job will be submitted by the API client but not processed remotely. Useful for obtaining cost estimates. Defaults to False. metadata (dict): optional metadata to attach to the job. Defaults to None. + memory: A boolean that determines whether to retrieve per shot results for the job + from IonQ servers, default is False. When False, shots will be generated locally + by sampling the retrieved probability distribution. extra_query_params: Specify any parameters to include in the request. Returns: @@ -329,7 +343,7 @@ def create_job( ) # The returned job does not have fully populated fields, so make # a second call and return the results of the fully filled out job. - return self.get_job(result['id']) + return self.get_job(result['id'], memory=memory) def create_batch_job( self, @@ -342,6 +356,7 @@ def create_batch_job( noise: dict | None = None, metadata: dict | None = None, dry_run: bool = False, + memory: bool = False, extra_query_params: dict | None = None, ) -> job.Job: """Create a new job to run the given circuit. @@ -371,6 +386,9 @@ def create_batch_job( dry_run: If True, the job will be submitted by the API client but not processed remotely. Useful for obtaining cost estimates. Defaults to False. metadata (dict): optional metadata to attach to the job. Defaults to None. + memory: A boolean that determines whether to retrieve per shot results for the job + from IonQ servers, default is False. When False, shots will be generated locally + by sampling the retrieved probability distribution. extra_query_params: Specify any parameters to include in the request. Returns: @@ -398,14 +416,16 @@ def create_batch_job( ) # The returned job does not have fully populated fields, so make # a second call and return the results of the fully filled out job. - return self.get_job(result['id']) + return self.get_job(result['id'], memory=memory) - def get_job(self, job_id: str) -> job.Job: + def get_job(self, job_id: str, memory: bool = False) -> job.Job: """Gets a job that has been created on the IonQ API. Args: job_id: The UUID of the job. Jobs are assigned these numbers by the - server during the creation of the job. + server during the creation of the job. + memory: A boolean that determines whether to retrieve per shot results + for the job from IonQ servers, default is False. Returns: A `cirq_ionq.IonQJob` which can be queried for status or results. @@ -415,7 +435,7 @@ def get_job(self, job_id: str) -> job.Job: IonQException: If there was an error accessing the API. """ job_dict = self._client.get_job(job_id=job_id) - return job.Job(client=self._client, job_dict=job_dict) + return job.Job(client=self._client, job_dict=job_dict, memory=memory) def list_jobs( self, status: str | None = None, limit: int = 100, batch_size: int = 1000 diff --git a/cirq-ionq/cirq_ionq/service_test.py b/cirq-ionq/cirq_ionq/service_test.py index e9c90767d21..49e1b1c4ad3 100644 --- a/cirq-ionq/cirq_ionq/service_test.py +++ b/cirq-ionq/cirq_ionq/service_test.py @@ -63,6 +63,21 @@ def test_service_run(target, expected_results): assert create_job_kwargs['name'] == 'bacon' +def test_service_run_passes_memory_to_create_job(): + service = ionq.Service(remote_host='http://example.com', api_key='key') + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.measure(q, key='m')) + 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 + + with mock.patch.object(service, 'create_job', return_value=mock_job) as create_job: + result = service.run(circuit=circuit, repetitions=1, target='qpu', memory=True) + + assert result == qpu_result.to_cirq_result(params=cirq.ParamResolver({})) + assert create_job.call_args.kwargs['memory'] is True + + @pytest.mark.parametrize( 'target,expected_results1,expected_results2', [ @@ -125,6 +140,21 @@ def test_service_run_batch(target, expected_results1, expected_results2): assert create_job_kwargs['name'] == 'bacon' +def test_service_run_batch_passes_memory_to_create_batch_job(): + service = ionq.Service(remote_host='http://example.com', api_key='key') + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.measure(q, key='m')) + 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 + + with mock.patch.object(service, 'create_batch_job', return_value=mock_job) as create_batch_job: + results = service.run_batch(circuits=[circuit], repetitions=1, target='qpu', memory=True) + + assert results == [qpu_result.to_cirq_result(params=cirq.ParamResolver({}))] + assert create_batch_job.call_args.kwargs['memory'] is True + + def test_sampler(): service = ionq.Service(remote_host='http://example.com', api_key='key') mock_client = mock.MagicMock() @@ -151,34 +181,67 @@ def test_sampler(): mock_client.create_job.assert_called_once() -def test_service_get_job(): +@pytest.mark.parametrize('memory', [True, False]) +def test_service_get_job(memory): service = ionq.Service(remote_host='http://example.com', api_key='key') mock_client = mock.MagicMock() job_dict = {'id': 'job_id', 'status': 'ready'} mock_client.get_job.return_value = job_dict service._client = mock_client - job = service.get_job('job_id') - assert job.job_id() == 'job_id' + with mock.patch('cirq_ionq.service.job.Job', autospec=True) as job_cls: + returned_job = service.get_job('job_id', memory=memory) + + assert returned_job is job_cls.return_value mock_client.get_job.assert_called_with(job_id='job_id') + job_cls.assert_called_once_with(client=mock_client, job_dict=job_dict, memory=memory) def test_service_create_job(): service = ionq.Service(remote_host='http://example.com', api_key='key') mock_client = mock.MagicMock() mock_client.create_job.return_value = {'id': 'job_id', 'status': 'ready'} - mock_client.get_job.return_value = {'id': 'job_id', 'status': 'completed'} service._client = mock_client circuit = cirq.Circuit(cirq.X(cirq.LineQubit(0))) - job = service.create_job(circuit=circuit, repetitions=100, target='qpu', name='bacon') - assert job.status() == 'completed' + mock_job = mock.MagicMock() + with mock.patch.object(service, 'get_job', return_value=mock_job) as get_job: + job = service.create_job( + circuit=circuit, repetitions=100, target='qpu', name='bacon', memory=True + ) + + assert job is mock_job + create_job_kwargs = mock_client.create_job.call_args[1] + # Serialization induces a float, so we don't validate full circuit. + assert create_job_kwargs['serialized_program'].input['qubits'] == 1 + assert create_job_kwargs['repetitions'] == 100 + assert create_job_kwargs['target'] == 'qpu' + assert create_job_kwargs['name'] == 'bacon' + get_job.assert_called_once_with('job_id', memory=True) + + +def test_service_create_batch_job(): + service = ionq.Service(remote_host='http://example.com', api_key='key') + mock_client = mock.MagicMock() + mock_client.create_job.return_value = {'id': 'job_id', 'status': 'ready'} + service._client = mock_client + + circuit = cirq.Circuit(cirq.X(cirq.LineQubit(0))) + mock_job = mock.MagicMock() + with mock.patch.object(service, 'get_job', return_value=mock_job) as get_job: + job = service.create_batch_job( + circuits=[circuit], repetitions=100, target='qpu', name='bacon', memory=True + ) + + assert job is mock_job create_job_kwargs = mock_client.create_job.call_args[1] # Serialization induces a float, so we don't validate full circuit. assert create_job_kwargs['serialized_program'].input['qubits'] == 1 assert create_job_kwargs['repetitions'] == 100 assert create_job_kwargs['target'] == 'qpu' assert create_job_kwargs['name'] == 'bacon' + assert create_job_kwargs['batch_mode'] is True + get_job.assert_called_once_with('job_id', memory=True) def test_service_list_jobs():