diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac525fd..079567dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed some typos (thanks to @eltociear, @Darkdragon84) +### Changed + +- Update ProjectQ to handle IonQ API v0.3 + ### Repository - Update GitHub workflows to work with merge queues diff --git a/projectq/backends/_ionq/_ionq.py b/projectq/backends/_ionq/_ionq.py index 4c59369c..c097985b 100644 --- a/projectq/backends/_ionq/_ionq.py +++ b/projectq/backends/_ionq/_ionq.py @@ -78,6 +78,8 @@ def __init__( verbose=False, token=None, device='ionq_simulator', + error_mitigation=None, + sharpen=None, num_retries=3000, interval=1, retrieve_execution=None, @@ -102,6 +104,8 @@ def __init__( """ super().__init__() self.device = device if use_hardware else 'ionq_simulator' + self.error_mitigation = error_mitigation + self._sharpen = sharpen self._num_runs = num_runs self._verbose = verbose self._token = token @@ -291,6 +295,9 @@ def _run(self): # pylint: disable=too-many-locals measured_ids = self._measured_ids[:] info = { 'circuit': self._circuit, + 'gateset': 'qis', + 'format': 'ionq.circuit.v0', + 'error_mitigation': self.error_mitigation, 'nq': len(qubit_mapping.keys()), 'shots': self._num_runs, 'meas_mapped': [qubit_mapping[qubit_id] for qubit_id in measured_ids], @@ -311,6 +318,7 @@ def _run(self): # pylint: disable=too-many-locals device=self.device, token=self._token, jobid=self._retrieve_execution, + sharpen=self._sharpen, num_retries=self._num_retries, interval=self._interval, verbose=self._verbose, diff --git a/projectq/backends/_ionq/_ionq_http_client.py b/projectq/backends/_ionq/_ionq_http_client.py index 72e16555..68d62a9d 100644 --- a/projectq/backends/_ionq/_ionq_http_client.py +++ b/projectq/backends/_ionq/_ionq_http_client.py @@ -30,7 +30,7 @@ RequestTimeoutError, ) -_API_URL = 'https://api.ionq.co/v0.2/' +_API_URL = 'https://api.ionq.co/v0.3/' _JOB_API_URL = urljoin(_API_URL, 'jobs/') @@ -40,6 +40,7 @@ class IonQ(Session): def __init__(self, verbose=False): """Initialize an session with IonQ's APIs.""" super().__init__() + self.user_agent() self.backends = {} self.timeout = 5.0 self.token = None @@ -59,7 +60,7 @@ def update_devices_list(self): }, 'ionq_qpu': { 'nq': 11, - 'target': 'qpu', + 'target': 'qpu.harmony', }, } for backend in r_json: @@ -101,6 +102,10 @@ def can_run_experiment(self, info, device): nb_qubit_needed = info['nq'] return nb_qubit_needed <= nb_qubit_max, nb_qubit_max, nb_qubit_needed + def user_agent(self): + """Set a User-Agent header for this session.""" + self.headers.update({'User-Agent': 'projectq-ionq/0.8.0'}) + def authenticate(self, token=None): """Set an Authorization header for this session. @@ -132,19 +137,22 @@ def run(self, info, device): str: The ID of a newly submitted Job. """ argument = { - 'target': self.backends[device]['target'], + 'target': self.backends[device].get('target'), 'metadata': { 'sdk': 'ProjectQ', - 'meas_qubit_ids': json.dumps(info['meas_qubit_ids']), + 'meas_qubit_ids': json.dumps(info.get('meas_qubit_ids')), }, - 'shots': info['shots'], - 'registers': {'meas_mapped': info['meas_mapped']}, - 'lang': 'json', - 'body': { - 'qubits': info['nq'], - 'circuit': info['circuit'], + 'shots': info.get('shots'), + 'registers': {'meas_mapped': info.get('meas_mapped')}, + 'input': { + 'format': info.get('format'), + 'gateset': info.get('gateset'), + 'qubits': info.get('nq'), + 'circuit': info.get('circuit'), }, } + if info.get('error_mitigation') is not None: + argument['error_mitigation'] = info['error_mitigation'] # _API_URL[:-1] strips the trailing slash. # TODO: Add comprehensive error parsing for non-200 responses. @@ -153,11 +161,11 @@ def run(self, info, device): # Process the response. r_json = req.json() - status = r_json['status'] + status = r_json.get('status') # Return the job id. if status == 'ready': - return r_json['id'] + return r_json.get('id') # Otherwise, extract any provided failure info and raise an exception. failure = r_json.get('failure') or { @@ -166,7 +174,9 @@ def run(self, info, device): } raise JobSubmissionError(f"{failure['code']}: {failure['error']} (status={status})") - def get_result(self, device, execution_id, num_retries=3000, interval=1): + def get_result( + self, device, execution_id, sharpen=None, num_retries=3000, interval=1 + ): # pylint: disable=too-many-arguments,too-many-locals """ Given a backend and ID, fetch the results for this job's execution. @@ -178,6 +188,8 @@ def get_result(self, device, execution_id, num_retries=3000, interval=1): Args: device (str): The device used to run this job. execution_id (str): An IonQ Job ID. + sharpen: A boolean that determines how to aggregate error mitigated. + If True, apply majority vote mitigation; if False, apply average mitigation. num_retries (int, optional): Number of times to retry the fetch before raising a timeout error. Defaults to 3000. interval (int, optional): Number of seconds to wait between retries. @@ -196,6 +208,10 @@ def get_result(self, device, execution_id, num_retries=3000, interval=1): if self._verbose: # pragma: no cover print(f"Waiting for results. [Job ID: {execution_id}]") + params = {} + if sharpen is not None: + params["sharpen"] = sharpen + original_sigint_handler = signal.getsignal(signal.SIGINT) def _handle_sigint_during_get_result(*_): # pragma: no cover @@ -205,18 +221,20 @@ def _handle_sigint_during_get_result(*_): # pragma: no cover try: for retries in range(num_retries): - req = super().get(urljoin(_JOB_API_URL, execution_id)) + req = super().get(urljoin(_JOB_API_URL, execution_id), params=params) req.raise_for_status() - r_json = req.json() - status = r_json['status'] + req_json = req.json() + status = req_json['status'] # Check if job is completed. if status == 'completed': - meas_mapped = r_json['registers']['meas_mapped'] - meas_qubit_ids = json.loads(r_json['metadata']['meas_qubit_ids']) - output_probs = r_json['data']['registers']['meas_mapped'] + r_get = super().get(urljoin(_JOB_API_URL, req_json.get('results_url')), params=params) + r_json = r_get.json() + meas_mapped = req_json['registers']['meas_mapped'] + meas_qubit_ids = json.loads(req_json['metadata']['meas_qubit_ids']) + output_probs = r_json return { - 'nq': r_json['qubits'], + 'nq': req_json['qubits'], 'output_probs': output_probs, 'meas_mapped': meas_mapped, 'meas_qubit_ids': meas_qubit_ids, @@ -255,6 +273,7 @@ def retrieve( device, token, jobid, + sharpen=None, num_retries=3000, interval=1, verbose=False, @@ -281,6 +300,7 @@ def retrieve( res = ionq_session.get_result( device, jobid, + sharpen=sharpen, num_retries=num_retries, interval=interval, ) diff --git a/projectq/backends/_ionq/_ionq_http_client_test.py b/projectq/backends/_ionq/_ionq_http_client_test.py index d1df8fb6..3987572b 100644 --- a/projectq/backends/_ionq/_ionq_http_client_test.py +++ b/projectq/backends/_ionq/_ionq_http_client_test.py @@ -50,7 +50,7 @@ def user_password_input(prompt): def test_is_online(monkeypatch): def mock_get(_self, path, *args, **kwargs): - assert 'https://api.ionq.co/v0.2/backends' == path + assert 'https://api.ionq.co/v0.3/backends' == path mock_response = mock.MagicMock() mock_response.json = mock.MagicMock( return_value=[ @@ -86,7 +86,7 @@ def mock_get(_self, path, *args, **kwargs): def test_show_devices(monkeypatch): def mock_get(_self, path, *args, **kwargs): - assert 'https://api.ionq.co/v0.2/backends' == path + assert 'https://api.ionq.co/v0.3/backends' == path mock_response = mock.MagicMock() mock_response.json = mock.MagicMock( return_value=[ @@ -168,8 +168,9 @@ def _dummy_update(_self): 'metadata': {'sdk': 'ProjectQ', 'meas_qubit_ids': '[2, 3]'}, 'shots': 1, 'registers': {'meas_mapped': [2, 3]}, - 'lang': 'json', - 'body': { + 'input': { + 'format': None, + 'gateset': None, 'qubits': 4, 'circuit': [ {'gate': 'x', 'targets': [0]}, @@ -182,7 +183,7 @@ def _dummy_update(_self): } def mock_post(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs' + assert path == 'https://api.ionq.co/v0.3/jobs' assert 'json' in kwargs assert expected_request == kwargs['json'] mock_response = mock.MagicMock() @@ -196,20 +197,25 @@ def mock_post(_self, path, *args, **kwargs): return mock_response def mock_get(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs/new-job-id' - mock_response = mock.MagicMock() - mock_response.json = mock.MagicMock( - return_value={ - 'id': 'new-job-id', - 'status': 'completed', - 'qubits': 4, - 'metadata': {'meas_qubit_ids': '[2, 3]'}, - 'registers': {'meas_mapped': [2, 3]}, - 'data': { - 'registers': {'meas_mapped': {'2': 1}}, - }, - } - ) + if path == 'https://api.ionq.co/v0.3/jobs/new-job-id': + mock_response = mock.MagicMock() + mock_response.json = mock.MagicMock( + return_value={ + 'id': 'new-job-id', + 'status': 'completed', + 'qubits': 4, + 'metadata': {'meas_qubit_ids': '[2, 3]'}, + 'registers': {'meas_mapped': [2, 3]}, + 'results_url': 'new-job-id/results', + } + ) + elif path == 'https://api.ionq.co/v0.3/jobs/new-job-id/results': + mock_response = mock.MagicMock() + mock_response.json = mock.MagicMock( + return_value={'2': 1}, + ) + else: + raise ValueError(f"Unexpected URL: {path}") return mock_response monkeypatch.setattr('requests.sessions.Session.post', mock_post) @@ -428,7 +434,7 @@ def _dummy_update(_self): ) def mock_post(_self, path, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs' + assert path == 'https://api.ionq.co/v0.3/jobs' mock_response = mock.MagicMock() mock_response.json = mock.MagicMock(return_value=err_data) return mock_response @@ -467,7 +473,7 @@ def _dummy_update(_self): ) def mock_post(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs' + assert path == 'https://api.ionq.co/v0.3/jobs' mock_response = mock.MagicMock() mock_response.json = mock.MagicMock( return_value={ @@ -478,7 +484,7 @@ def mock_post(_self, path, *args, **kwargs): return mock_response def mock_get(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs/new-job-id' + assert path == 'https://api.ionq.co/v0.3/jobs/new-job-id' mock_response = mock.MagicMock() mock_response.json = mock.MagicMock( return_value={ @@ -525,28 +531,25 @@ def _dummy_update(_self): 'update_devices_list', _dummy_update.__get__(None, _ionq_http_client.IonQ), ) - request_num = [0] + # request_num = [0] def mock_get(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs/old-job-id' - json_response = { - 'id': 'old-job-id', - 'status': 'running', - } - if request_num[0] > 1: + if path == 'https://api.ionq.co/v0.3/jobs/old-job-id': json_response = { 'id': 'old-job-id', 'status': 'completed', 'qubits': 4, 'registers': {'meas_mapped': [2, 3]}, 'metadata': {'meas_qubit_ids': '[2, 3]'}, - 'data': { - 'registers': {'meas_mapped': {'2': 1}}, - }, + 'results_url': 'old-job-id/results', } + elif path == 'https://api.ionq.co/v0.3/jobs/old-job-id/results': + json_response = {'2': 1} + else: + raise ValueError(f"Unexpected URL: {path}") + mock_response = mock.MagicMock() mock_response.json = mock.MagicMock(return_value=json_response) - request_num[0] += 1 return mock_response monkeypatch.setattr('requests.sessions.Session.get', mock_get) @@ -566,12 +569,8 @@ def user_password_input(prompt): # Code to test: # Called once per loop in _get_result while the job is not ready. - mock_sleep = mock.MagicMock() - monkeypatch.setattr(_ionq_http_client.time, 'sleep', mock_sleep) result = _ionq_http_client.retrieve('dummy', token, 'old-job-id') assert expected == result - # We only sleep twice. - assert 2 == mock_sleep.call_count def test_retrieve_that_errors_are_caught(monkeypatch): @@ -586,7 +585,7 @@ def _dummy_update(_self): request_num = [0] def mock_get(_self, path, *args, **kwargs): - assert path == 'https://api.ionq.co/v0.2/jobs/old-job-id' + assert path == 'https://api.ionq.co/v0.3/jobs/old-job-id' json_response = { 'id': 'old-job-id', 'status': 'running', diff --git a/projectq/backends/_ionq/_ionq_test.py b/projectq/backends/_ionq/_ionq_test.py index 5a846b03..867e8e3c 100644 --- a/projectq/backends/_ionq/_ionq_test.py +++ b/projectq/backends/_ionq/_ionq_test.py @@ -393,12 +393,16 @@ def mock_retrieve(*args, **kwargs): def test_ionq_backend_functional_test(monkeypatch, mapper_factory): - """Test that the backend can handle a valid circuit with valid results.""" + """Test that sub-classed or aliased gates are handled correctly.""" + # using alias gates, for coverage expected = { 'nq': 3, 'shots': 10, 'meas_mapped': [1, 2], 'meas_qubit_ids': [1, 2], + 'error_mitigation': None, + 'format': 'ionq.circuit.v0', + 'gateset': 'qis', 'circuit': [ {'gate': 'ry', 'rotation': 0.5, 'targets': [1]}, {'gate': 'rx', 'rotation': 0.5, 'targets': [1]}, @@ -425,7 +429,7 @@ def mock_send(*args, **kwargs): backend = _ionq.IonQBackend(verbose=True, num_runs=10) eng = MainEngine( backend=backend, - engine_list=[mapper_factory()], + engine_list=[mapper_factory(9)], verbose=True, ) unused_qubit = eng.allocate_qubit() # noqa: F841 @@ -458,6 +462,9 @@ def test_ionq_backend_functional_aliases_test(monkeypatch, mapper_factory): 'shots': 10, 'meas_mapped': [2, 3], 'meas_qubit_ids': [2, 3], + 'error_mitigation': None, + 'format': 'ionq.circuit.v0', + 'gateset': 'qis', 'circuit': [ {'gate': 'x', 'targets': [0]}, {'gate': 'x', 'targets': [1]},