diff --git a/AGENTS.md b/AGENTS.md index eb67dd5..5ab0ed6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ Auth is `apiKey`, **not** `Bearer`. `IonQClient` sets `prefix="apiKey"`; the wir - Mock HTTP with `httpx_mock` from `pytest-httpx`. Don't introduce `responses`, `requests-mock`, or VCR. - Integration tests are marked `pytest.mark.integration` and live in `tests/integration/`. Use the `track_job` fixture so the autouse `cleanup_jobs` fixture deletes anything you create. - `gates.py` is intentionally NumPy-free (`cmath`, `math`, nested tuples). Keep it that way. +- `results.py` is intentionally NumPy-free. Keep it that way. ## Drift sentinels — single edits that fan out diff --git a/CHANGELOG.md b/CHANGELOG.md index 706ed79..f5dd196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `get_job_artifact` endpoint (`GET /jobs/{UUID}/artifacts/{artifactId}`) for downloading job artifacts by id. The response body is opaque, so only the `sync_detailed` / `asyncio_detailed` callables are generated; read the bytes off `Response.content`. - `Backend` now exposes `supported_gates`, `supported_native_gates`, and `supported_error_mitigations`. - `estimate_job_cost` response gained `estimated_quantum_compute_time_us`, and its `rate_information` gained `qct_cost_cents` and `rate_type` (`"qct"` or `"2qge"`). Its `cost_1q_gate`, `cost_2q_gate`, and `job_cost_minimum` rate fields are now nullable. +- `ionq_core.results` module with pure-Python post-processing helpers over the probabilities mapping: `probabilities_to_counts`, `relabel_to_bitstrings`, `marginal`, and `expectation_z`. Keys are little-endian (qubit 0 is the least significant bit). ### Changed diff --git a/custom-templates/package_init.py.jinja b/custom-templates/package_init.py.jinja index d05c1c0..bfbbf50 100644 --- a/custom-templates/package_init.py.jinja +++ b/custom-templates/package_init.py.jinja @@ -1,5 +1,5 @@ {% from "helpers.jinja" import safe_docstring %} -{% set modules = ["exceptions", "extensions", "gates", "ionq_client", "pagination", "polling", "session"] %} +{% set modules = ["exceptions", "extensions", "gates", "ionq_client", "pagination", "polling", "results", "session"] %} {{ safe_docstring(package_description) }} from . import {{ modules | join(", ") }} from .client import AuthenticatedClient, Client # noqa: F401 diff --git a/ionq_core/__init__.py b/ionq_core/__init__.py index 47ecfb9..1d3f96a 100644 --- a/ionq_core/__init__.py +++ b/ionq_core/__init__.py @@ -4,7 +4,7 @@ """A client library for accessing IonQ Cloud Platform API""" -from . import exceptions, extensions, gates, ionq_client, pagination, polling, session +from . import exceptions, extensions, gates, ionq_client, pagination, polling, results, session from .client import AuthenticatedClient, Client # noqa: F401 from .exceptions import * # noqa: F403 from .extensions import * # noqa: F403 @@ -12,6 +12,7 @@ from .ionq_client import * # noqa: F403 from .pagination import * # noqa: F403 from .polling import * # noqa: F403 +from .results import * # noqa: F403 from .session import * # noqa: F403 from .types import UNSET, Unset # noqa: F401 @@ -23,6 +24,7 @@ "ionq_client", "pagination", "polling", + "results", "session", "AuthenticatedClient", "Client", @@ -34,6 +36,7 @@ *ionq_client.__all__, *pagination.__all__, *polling.__all__, + *results.__all__, *session.__all__, } ) diff --git a/ionq_core/results.py b/ionq_core/results.py new file mode 100644 index 0000000..77d6b00 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python results post-processing helpers. + +This module provides helpers over the register-keyed probability mapping +returned by IonQ's results endpoints. + +Qubit ordering: + IonQ probability results map integer state keys (as strings) to + probabilities. Throughout this module, qubit i corresponds to + bit 2^i in the integer key -- i.e. qubit 0 is the least significant bit (LSB). + + For example, given a 3-qubit circuit the integer key ``4`` + (binary ``100``) encodes qubit 0 = 0, qubit 1 = 0, qubit 2 = 1. + +Example: + ```python + from ionq_core import probabilities_to_counts, relabel_to_bitstrings, marginal, expectation_z + + counts = probabilities_to_counts({"0": 0.4, "3": 0.6}, 100) # translate probability results into counts + bitstrings = relabel_to_bitstrings({"0": 0.4, "3": 0.6}, 2) # relabel to bitstrings + marginal_probabilities = marginal({"0": 0.4, "3": 0.6}, [0], 2) # compute the marginal probabilities over qubit 0 + expectation = expectation_z({"0": 0.4, "3": 0.6}, 2) # compute the expectation value of the Z-basis observable + ``` +""" + +__all__ = [ + "expectation_z", + "marginal", + "probabilities_to_counts", + "relabel_to_bitstrings", +] + +import math +from collections import defaultdict +from collections.abc import Mapping, Sequence + + +def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]: + """Convert a probability mapping to integer counts. + + Uses the largest-remainder method so that counts sum exactly to `shots`. + + Args: + probabilities: Mapping from integer state keys (as strings) to probabilities. + shots: Total number of counts. + + Returns: + Mapping from the same keys to integer counts. + + Raises: + ValueError: If ``shots`` is negative. + + Examples: + ```python + >>> probabilities_to_counts({"0": 0.4, "3": 0.6}, 100) # counts with 100 shots + {'0': 40, '3': 60} + ``` + """ + if shots < 0: + raise ValueError("Number of shots must be non-negative") + result = {} + remainders = [] + remaining_shots = shots + for state, probability in probabilities.items(): + result[state] = math.floor(probability * shots) + remainders.append((probability * shots - result[state], state)) + remaining_shots -= result[state] + + remainders.sort(key=lambda x: (-x[0], int(x[1]))) + for _remainder, state in remainders[:remaining_shots]: + result[state] += 1 + return result + + +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Convert integer state keys to zero-padded bitstrings. + + The most-significant bit is on the left, producing bitstrings in + ``q(n-1) ... q1 q0`` order (qubit 0 rightmost). + + Args: + probabilities: Mapping from integer state keys (as strings) to probabilities. + num_qubits: Number of qubits used to pad the bitstring. + + Returns: + Mapping from bitstrings to probabilities. + + Raises: + ValueError: If ``num_qubits`` is not positive, or a state key does not + fit in ``num_qubits`` qubits. + + Examples: + ```python + >>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 2) + {'00': 0.25, '01': 0.25, '10': 0.25, '11': 0.25} + >>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 3) # example with 3 qubits + {'000': 0.25, '001': 0.25, '010': 0.25, '011': 0.25} + ``` + """ + if num_qubits <= 0: + raise ValueError("Number of qubits must be positive") + _check_states_in_range(probabilities, num_qubits) + result = {} + for state, probability in probabilities.items(): + result[format(int(state), f"0{num_qubits}b")] = probability + return result + + +def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]: + """Compute the marginal probabilities over a subset of qubits. + + Qubit indices follow the module convention: qubit i is bit 2^i + in the integer state key. The output keys are new integers where the + selected qubits are packed in the order given by ``qubits`` -- + ``qubits[0]`` becomes the most significant bit of the output key. + + Args: + probabilities: Mapping from integer state keys (as strings) to probabilities. + qubits: Qubit indices to keep. The order matters: ``qubits[0]`` maps + to the highest bit in the output key. + num_qubits: Total number of qubits in the original state. + + Returns: + Mapping from integer state keys (as strings) to marginal probabilities. + + Raises: + ValueError: If ``qubits`` is empty, has duplicate or negative indices, + or indexes past ``num_qubits``; if ``num_qubits`` is not positive; + or if a state key does not fit in ``num_qubits`` qubits. + + Examples: + ```python + >>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [0], 2) # keep qubit 0 (bit 2^0) + {'0': 0.4, '1': 0.6} + >>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [1,0], 2) # keep both, q1 q0 order + {'0': 0.1, '1': 0.2, '2': 0.3, '3': 0.4} + >>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [0,1], 2) # keep both, q0 q1 order (swapped) + {'0': 0.1, '1': 0.3, '2': 0.2, '3': 0.4} + ``` + """ + if not qubits: + raise ValueError("Qubits sequence cannot be empty") + if min(qubits) < 0: + raise ValueError("Qubit indices must be non-negative") + if num_qubits <= 0: + raise ValueError("Number of qubits must be positive") + _check_states_in_range(probabilities, num_qubits) + if max(qubits) >= num_qubits: + raise ValueError("Qubit indices must be less than the number of qubits") + if len(set(qubits)) != len(qubits): + raise ValueError("Qubit indices must be unique") + result = defaultdict(float) + for state_string, probability in probabilities.items(): + state = int(state_string) + marginalized_state = 0 + for qubit in qubits: + marginalized_state = (marginalized_state << 1) + ((state >> qubit) & 1) + result[str(marginalized_state)] += probability + + return dict(result) + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """Calculate the expectation value of the all-qubit parity observable (Z on every qubit). + + Each computational basis state contributes +1 when the total number of + qubits in |1> is even, and -1 when odd. This is independent of qubit + ordering. + + Args: + probabilities: Mapping from integer state keys (as strings) to probabilities. + num_qubits: Total number of qubits. + + Returns: + Expectation value observed in the computational basis (Z basis). + + Raises: + ValueError: If ``num_qubits`` is not positive, or a state key does not + fit in ``num_qubits`` qubits. + + Examples: + ```python + >>> expectation_z({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, 2) # even-parity states: 0,3; odd-parity: 1,2 + 0.0 + ``` + """ + if num_qubits <= 0: + raise ValueError("Number of qubits must be positive") + _check_states_in_range(probabilities, num_qubits) + result = 0.0 + for state, probability in probabilities.items(): + result += (1 - 2 * (int(state).bit_count() & 1)) * probability + return result + + +def _check_states_in_range(probabilities: Mapping[str, float], num_qubits: int) -> None: + """Raise if any integer state key does not fit in ``num_qubits`` qubits.""" + if not probabilities: + return + max_state = max(int(state) for state in probabilities) + if max_state >= (1 << num_qubits): + raise ValueError(f"State {max_state} is out of range for {num_qubits} qubits") diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..78de236 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,116 @@ +import pytest + +from ionq_core.results import ( + expectation_z, + marginal, + probabilities_to_counts, + relabel_to_bitstrings, +) + +TOLERANCE = 1e-12 + + +def _approx(actual, expected, tol=TOLERANCE): + assert actual.keys() == expected.keys(), f"keys differ: {actual.keys()} != {expected.keys()}" + for key in expected: + assert abs(actual[key] - expected[key]) < tol, f"key {key!r}: {actual[key]} != {expected[key]}" + + +class TestProbabilitiesToCounts: + @pytest.mark.parametrize( + ("probabilities", "shots", "expected"), + [ + ({"0": 0.4, "3": 0.6}, 100, {"0": 40, "3": 60}), + ({"0": 0.496, "3": 0.504}, 100, {"0": 50, "3": 50}), + ({"0": 0.251, "1": 0.243, "2": 0.254, "3": 0.252}, 100, {"0": 25, "1": 24, "2": 26, "3": 25}), + ({"0": 0.328, "1": 0.332, "2": 0.139, "3": 0.191}, 100, {"0": 33, "1": 34, "2": 14, "3": 19}), + ({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 50, {"0": 13, "1": 13, "2": 12, "3": 12}), + ({}, 100, {}), + ({"0": 0.5, "3": 0.5}, 0, {"0": 0, "3": 0}), + ], + ) + def test_counts(self, probabilities, shots, expected): + assert probabilities_to_counts(probabilities, shots) == expected + + def test_negative_shots(self): + with pytest.raises(ValueError, match="Number of shots must be non-negative"): + probabilities_to_counts({"0": 0.5, "3": 0.5}, -1) + + +class TestRelabelToBitstrings: + @pytest.mark.parametrize( + ("probabilities", "num_qubits", "expected"), + [ + ({"1": 0.4, "2": 0.6}, 2, {"01": 0.4, "10": 0.6}), + ({"1": 0.4, "2": 0.6}, 3, {"001": 0.4, "010": 0.6}), + ({}, 3, {}), + ], + ) + def test_relabel(self, probabilities, num_qubits, expected): + _approx(relabel_to_bitstrings(probabilities, num_qubits), expected) + + @pytest.mark.parametrize( + ("num_qubits", "match"), + [ + (1, "State 2 is out of range for 1 qubits"), + (-1, "Number of qubits must be positive"), + ], + ) + def test_invalid(self, num_qubits, match): + with pytest.raises(ValueError, match=match): + relabel_to_bitstrings({"1": 0.4, "2": 0.6}, num_qubits) + + +class TestMarginal: + @pytest.mark.parametrize( + ("probabilities", "qubits", "num_qubits", "expected"), + [ + ({"0": 0.4, "3": 0.6}, [0], 2, {"0": 0.4, "1": 0.6}), + ({"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.4}, [0], 2, {"0": 0.4, "1": 0.6}), + ({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, [1, 0], 3, {"0": 0.4, "2": 0.2, "3": 0.4}), + ({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, [0, 1], 3, {"0": 0.4, "1": 0.2, "3": 0.4}), + ({}, [0], 1, {}), + ], + ) + def test_marginal(self, probabilities, qubits, num_qubits, expected): + _approx(marginal(probabilities, qubits, num_qubits), expected) + + @pytest.mark.parametrize( + ("qubits", "num_qubits", "match"), + [ + ([0], -1, "Number of qubits must be positive"), + ([0], 1, "State 3 is out of range for 1 qubits"), + ([2], 2, "Qubit indices must be less than the number of qubits"), + ([-1], 2, "Qubit indices must be non-negative"), + ([0, 0], 2, "Qubit indices must be unique"), + ([], 2, "Qubits sequence cannot be empty"), + ], + ) + def test_invalid(self, qubits, num_qubits, match): + with pytest.raises(ValueError, match=match): + marginal({"0": 0.4, "3": 0.6}, qubits, num_qubits) + + +class TestExpectationZ: + @pytest.mark.parametrize( + ("probabilities", "num_qubits", "expected"), + [ + ({"0": 0.4, "3": 0.6}, 2, 1.0), + ({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 2, 0.0), + ({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, 3, -0.8), + ({}, 2, 0.0), + ], + ) + def test_expectation(self, probabilities, num_qubits, expected): + assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE + + @pytest.mark.parametrize( + ("num_qubits", "match"), + [ + (1, "State 3 is out of range for 1 qubits"), + (-1, "Number of qubits must be positive"), + ], + ) + def test_invalid(self, num_qubits, match): + with pytest.raises(ValueError, match=match): + expectation_z({"0": 0.4, "3": 0.6}, num_qubits)