diff --git a/AGENTS.md b/AGENTS.md index a6ad7d2..e17028a 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/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..203699b --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,202 @@ +# 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. + + 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, little_endian: bool = False +) -> dict[str, float]: + """Convert integer state keys to zero-padded bitstrings. + + By default the most-significant bit is on the left, producing + bitstrings in ``q(n-1) ... q1 q0`` order. + Set ``little_endian=True`` to reverse the string so that qubit 0 is + on the left (``q0 q1 ... q(n-1)``). + + Args: + probabilities: Mapping from integer state keys (as strings) to probabilities. + num_qubits: Number of qubits used to pad the bitstring. + little_endian: If ``True``, reverse the bitstring so that qubit 0 appears + on the left (index 0). The default ``False`` puts qubit 0 on the + right (standard binary / MSB-first order). + + Returns: + Mapping from bitstrings to probabilities. + + Examples: + ```python + >>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 2) # default: MSB-first + {'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} + >>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 2, little_endian=True) # LSB-first + {'00': 0.25, '10': 0.25, '01': 0.25, '11': 0.25} + ``` + """ + if num_qubits <= 0: + raise ValueError("Number of qubits must be positive.") + if _max_qubits(probabilities) >= (1 << num_qubits): + raise ValueError(f"State {_max_qubits(probabilities)} is out of range for {num_qubits} qubits.") + result = {} + for state, probability in probabilities.items(): + bitstring = format(int(state), f"0{num_qubits}b") + if little_endian: + bitstring = bitstring[::-1] + result[bitstring] = 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. + + 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.") + if _max_qubits(probabilities) >= (1 << num_qubits): + raise ValueError(f"State {_max_qubits(probabilities)} is out of range for {num_qubits} 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("Qubits sequence must be non-duplicated") + 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 Z-basis (Z^(⊗n)) observable. + + 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). + + 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 + ``` + """ + if num_qubits < 0: + raise ValueError("Number of qubits must be positive.") + if _max_qubits(probabilities) >= (1 << num_qubits): + raise ValueError(f"State {_max_qubits(probabilities)} is out of range for {num_qubits} qubits.") + result = 0 + for state, probability in probabilities.items(): + result += (1 - 2 * (int(state).bit_count() & 1)) * probability + return result + + +def _max_qubits(probabilities: Mapping[str, float]) -> int: + """Validate that `num_qubits` is mathematically sufficient to represent all states.""" + if not probabilities: + return -1 + return max(int(state) for state in probabilities) diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..17733df --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,227 @@ +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: + def test_simple_case(self): + probabilities = {"0": 0.4, "3": 0.6} + shots = 100 + expected = {"0": 40, "3": 60} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_rounding_case(self): + probabilities = {"0": 0.496, "3": 0.504} + shots = 100 + expected = {"0": 50, "3": 50} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_multiple_rounding_case_1(self): + probabilities = {"0": 0.251, "1": 0.243, "2": 0.254, "3": 0.252} + shots = 100 + expected = {"0": 25, "1": 24, "2": 26, "3": 25} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_multiple_rounding_case_2(self): + probabilities = {"0": 0.328, "1": 0.332, "2": 0.139, "3": 0.191} + shots = 100 + expected = {"0": 33, "1": 34, "2": 14, "3": 19} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_break_even(self): + probabilities = {"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25} + shots = 50 + expected = {"0": 13, "1": 13, "2": 12, "3": 12} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_empty_input(self): + probabilities = {} + shots = 100 + expected = {} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_zero_shots(self): + probabilities = {"0": 0.5, "3": 0.5} + shots = 0 + expected = {"0": 0, "3": 0} + assert probabilities_to_counts(probabilities, shots) == expected + + def test_negative_shots(self): + probabilities = {"0": 0.5, "3": 0.5} + shots = -1 + with pytest.raises(ValueError, match=r"Number of shots must be non-negative."): + probabilities_to_counts(probabilities, shots) + + +class TestRelabelToBitstrings: + def test_simple_case(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = 2 + expected = {"01": 0.4, "10": 0.6} + _approx(relabel_to_bitstrings(probabilities, num_qubits), expected) + + def test_simple_case_little_endian(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = 2 + expected = {"10": 0.4, "01": 0.6} + _approx(relabel_to_bitstrings(probabilities, num_qubits, little_endian=True), expected) + + def test_more_qubits(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = 3 + expected = {"001": 0.4, "010": 0.6} + _approx(relabel_to_bitstrings(probabilities, num_qubits), expected) + + def test_more_qubits_little_endian(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = 3 + expected = {"100": 0.4, "010": 0.6} + _approx(relabel_to_bitstrings(probabilities, num_qubits, little_endian=True), expected) + + def test_empty_input(self): + probabilities = {} + num_qubits = 3 + expected = {} + _approx(relabel_to_bitstrings(probabilities, num_qubits), expected) + + def test_out_of_range_qubits(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = 1 + with pytest.raises(ValueError, match=r"State 2 is out of range for 1 qubits."): + relabel_to_bitstrings(probabilities, num_qubits) + + def test_negative_qubits(self): + probabilities = {"1": 0.4, "2": 0.6} + num_qubits = -1 + with pytest.raises(ValueError, match=r"Number of qubits must be positive."): + relabel_to_bitstrings(probabilities, num_qubits) + + +class TestMarginal: + def test_simple_case(self): + probablities = {"0": 0.4, "3": 0.6} + qubits = [0] + num_qubits = 2 + expected = {"0": 0.4, "1": 0.6} + _approx(marginal(probablities, qubits, num_qubits), expected) + + def test_simple_case_2(self): + probabilities = {"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.4} + qubits = [0] + num_qubits = 2 + expected = {"0": 0.4, "1": 0.6} + _approx(marginal(probabilities, qubits, num_qubits), expected) + + def test_simple_case_3(self): + probabilities = {"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4} + qubits = [1, 0] + num_qubits = 3 + expected = {"0": 0.4, "2": 0.2, "3": 0.4} + _approx(marginal(probabilities, qubits, num_qubits), expected) + + def test_reordering_qubits(self): + probabilities = {"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4} + qubits = [0, 1] + num_qubits = 3 + expected = {"0": 0.4, "1": 0.2, "3": 0.4} + _approx(marginal(probabilities, qubits, num_qubits), expected) + + def test_empty_input(self): + probabilities = {} + qubits = [0] + num_qubits = 1 + expected = {} + _approx(marginal(probabilities, qubits, num_qubits), expected) + + def test_negative_qubit(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [0] + num_qubits = -1 + with pytest.raises(ValueError, match=r"Number of qubits must be positive."): + marginal(probabilities, qubits, num_qubits) + + def test_out_of_range_qubit(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [0] + num_qubits = 1 + with pytest.raises(ValueError, match=r"State 3 is out of range for 1 qubits."): + marginal(probabilities, qubits, num_qubits) + + def test_qubit_index_out_of_range(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [2] + num_qubits = 2 + with pytest.raises(ValueError, match=r"Qubit indices must be less than the number of qubits."): + marginal(probabilities, qubits, num_qubits) + + def test_qubit_index_negative(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [-1] + num_qubits = 2 + with pytest.raises(ValueError, match=r"Qubit indices must be non-negative."): + marginal(probabilities, qubits, num_qubits) + + def test_qubit_index_duplicated(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [0, 0] + num_qubits = 2 + with pytest.raises(ValueError, match="Qubits sequence must be non-duplicated"): + marginal(probabilities, qubits, num_qubits) + + def test_empty_qubits(self): + probabilities = {"0": 0.4, "3": 0.6} + qubits = [] + num_qubits = 2 + with pytest.raises(ValueError, match=r"Qubits sequence cannot be empty."): + marginal(probabilities, qubits, num_qubits) + + +class TestExpectationZ: + def test_simple_case(self): + probabilities = {"0": 0.4, "3": 0.6} + num_qubits = 2 + expected = 1 + assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE + + def test_simple_case_2(self): + probabilities = {"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25} + num_qubits = 2 + expected = 0 + assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE + + def test_simple_case_3(self): + probabilities = {"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4} + num_qubits = 3 + expected = -0.8 + assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE + + def test_empty_input(self): + probabilities = {} + num_qubits = 2 + expected = 0 + assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE + + def test_qubit_out_of_range(self): + probabilities = {"0": 0.4, "3": 0.6} + num_qubits = 1 + with pytest.raises(ValueError, match=r"State 3 is out of range for 1 qubits."): + expectation_z(probabilities, num_qubits) + + def test_negative_qubits(self): + probabilities = {"0": 0.4, "3": 0.6} + num_qubits = -1 + with pytest.raises(ValueError, match=r"Number of qubits must be positive."): + expectation_z(probabilities, num_qubits)