From 181b0b3a5fecae858802f3833a938fe82729e5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=87=95=E8=B5=84=E4=BC=9F?= <> Date: Thu, 11 Jun 2026 19:38:43 +0800 Subject: [PATCH] Add results post-processing helpers --- CHANGELOG.md | 1 + custom-templates/package_init.py.jinja | 2 +- ionq_core/__init__.py | 5 +- ionq_core/results.py | 150 +++++++++++++++++++++++++ tests/test_results.py | 108 ++++++++++++++++++ 5 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 ionq_core/results.py create mode 100644 tests/test_results.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..33c4a1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Pure-Python results post-processing helpers for converting probabilities to counts, bitstring labels, marginals, and full-register Z expectation values. - `QctrlQaoaJobCreationPayload` and `QctrlQaoaJobInput` for submitting Q-CTRL QAOA maxcut combinatorial-optimization jobs via `create_job`. The `create_job` body union now also accepts `QctrlQaoaJobCreationPayload`. - `cost_model` optional field on `BaseJob`, `GetCircuitJobResponse`, and `GetJobResponse`, typed as `CostModel` (`"quantum_compute_time"` or `"execution_time"`). 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..f1cc030 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python helpers for post-processing IonQ probability results. + +IonQ probability endpoints return mappings from integer-encoded computational +basis states to probabilities. These helpers treat qubit 0 as the least +significant bit, so state ``"1"`` on a two-qubit result is bitstring ``"01"`` +when displayed in conventional most-significant-bit-first order. +""" + +__all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] + +import math +from collections.abc import Iterable, Mapping + + +def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]: + """Convert probabilities to integer counts that sum exactly to ``shots``. + + Counts are produced with largest-remainder rounding. Ties are broken by the + integer state key, making the result deterministic. + + Args: + probabilities: Mapping of integer-encoded state keys to probabilities. + shots: Total number of shots to distribute. + + Returns: + A count mapping with the same keys as ``probabilities``. + + Raises: + ValueError: If ``shots`` is negative or the probabilities are not + compatible with exact largest-remainder rounding to ``shots``. + """ + if shots < 0: + msg = "shots must be non-negative" + raise ValueError(msg) + + quotas = {state: probability * shots for state, probability in probabilities.items()} + counts = {state: math.floor(quota) for state, quota in quotas.items()} + remaining = shots - sum(counts.values()) + + if remaining < 0 or remaining > len(counts): + msg = "probabilities must sum close enough to 1 to allocate exactly shots counts" + raise ValueError(msg) + if remaining == 0: + return counts + + ranked_states = sorted(quotas, key=lambda state: (-(quotas[state] - counts[state]), _state_index(state))) + for state in ranked_states[:remaining]: + counts[state] += 1 + return counts + + +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Relabel integer state keys as zero-padded bitstrings. + + Bitstrings are returned most-significant-bit first for readability. Qubit 0 + is the least significant bit, so state ``"1"`` becomes ``"01"`` for + ``num_qubits=2``. + + Args: + probabilities: Mapping of integer-encoded state keys to probabilities. + num_qubits: Number of measured qubits. + + Returns: + A probability mapping keyed by zero-padded bitstrings. + """ + _validate_num_qubits(num_qubits) + return { + format(_parse_state_key(state, num_qubits), f"0{num_qubits}b"): probability + for state, probability in probabilities.items() + } + + +def marginal(probabilities: Mapping[str, float], qubits: Iterable[int], num_qubits: int) -> dict[str, float]: + """Compute a marginal probability distribution over selected qubits. + + The output bitstring follows the order of ``qubits``. For example, with + ``qubits=[0, 2]``, the first output bit is qubit 0 and the second output bit + is qubit 2. Qubit 0 is the least significant bit of each integer state key. + + Args: + probabilities: Mapping of integer-encoded state keys to probabilities. + qubits: Qubit indices to keep. + num_qubits: Number of measured qubits. + + Returns: + A probability mapping over the selected qubits. + """ + selected = _validate_qubits(qubits, num_qubits) + marginals: dict[str, float] = {} + for state_key, probability in probabilities.items(): + state = _parse_state_key(state_key, num_qubits) + projected = "".join("1" if state & (1 << qubit) else "0" for qubit in selected) + marginals[projected] = marginals.get(projected, 0.0) + probability + return marginals + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """Compute the full-register ``Z tensor ... tensor Z`` expectation value. + + This is ``sum(p(x) * (-1) ** popcount(x))`` over all integer-encoded states. + Qubit 0 is the least significant bit. + + Args: + probabilities: Mapping of integer-encoded state keys to probabilities. + num_qubits: Number of measured qubits. + + Returns: + The parity expectation value. + """ + _validate_num_qubits(num_qubits) + total = 0.0 + for state_key, probability in probabilities.items(): + state = _parse_state_key(state_key, num_qubits) + sign = -1.0 if state.bit_count() % 2 else 1.0 + total += sign * probability + return total + + +def _state_index(state: str) -> int: + return int(state) + + +def _parse_state_key(state: str, num_qubits: int) -> int: + parsed = _state_index(state) + if parsed < 0 or parsed >= 1 << num_qubits: + msg = f"state key {state!r} does not fit in {num_qubits} qubits" + raise ValueError(msg) + return parsed + + +def _validate_num_qubits(num_qubits: int) -> None: + if num_qubits < 0: + msg = "num_qubits must be non-negative" + raise ValueError(msg) + + +def _validate_qubits(qubits: Iterable[int], num_qubits: int) -> tuple[int, ...]: + _validate_num_qubits(num_qubits) + selected = tuple(qubits) + if len(set(selected)) != len(selected): + msg = "qubits must not contain duplicates" + raise ValueError(msg) + for qubit in selected: + if qubit < 0 or qubit >= num_qubits: + msg = f"qubit {qubit} is outside the range [0, {num_qubits})" + raise ValueError(msg) + return selected diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..da6867d --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,108 @@ +import pytest + +from ionq_core import expectation_z, marginal, probabilities_to_counts, relabel_to_bitstrings + +BELL_PROBABILITIES = {"0": 0.5, "3": 0.5} + + +def test_probabilities_to_counts_uses_largest_remainder_rounding(): + assert probabilities_to_counts({"0": 0.333, "1": 0.333, "2": 0.334}, 10) == { + "0": 3, + "1": 3, + "2": 4, + } + + +def test_probabilities_to_counts_breaks_ties_by_integer_state(): + assert probabilities_to_counts({"3": 0.25, "2": 0.25, "1": 0.25, "0": 0.25}, 2) == { + "3": 0, + "2": 0, + "1": 1, + "0": 1, + } + + +def test_probabilities_to_counts_returns_exact_integer_counts_for_bell_state(): + counts = probabilities_to_counts(BELL_PROBABILITIES, 101) + + assert counts == {"0": 51, "3": 50} + assert sum(counts.values()) == 101 + + +def test_probabilities_to_counts_returns_floor_counts_without_remainder(): + assert probabilities_to_counts(BELL_PROBABILITIES, 100) == {"0": 50, "3": 50} + + +def test_probabilities_to_counts_rejects_negative_shots(): + with pytest.raises(ValueError, match="shots must be non-negative"): + probabilities_to_counts(BELL_PROBABILITIES, -1) + + +def test_probabilities_to_counts_rejects_probabilities_too_large(): + with pytest.raises(ValueError, match="probabilities must sum"): + probabilities_to_counts({"0": 0.75, "1": 0.75}, 10) + + +def test_probabilities_to_counts_rejects_probabilities_too_small(): + with pytest.raises(ValueError, match="probabilities must sum"): + probabilities_to_counts({"0": 0.1, "1": 0.1}, 10) + + +def test_relabel_to_bitstrings_zero_pads_integer_state_keys(): + assert relabel_to_bitstrings({"0": 0.5, "1": 0.25, "5": 0.25}, 3) == { + "000": 0.5, + "001": 0.25, + "101": 0.25, + } + + +def test_relabel_to_bitstrings_rejects_negative_num_qubits(): + with pytest.raises(ValueError, match="num_qubits must be non-negative"): + relabel_to_bitstrings(BELL_PROBABILITIES, -1) + + +def test_relabel_to_bitstrings_rejects_state_outside_register(): + with pytest.raises(ValueError, match="does not fit"): + relabel_to_bitstrings({"4": 1.0}, 2) + + +def test_marginal_keeps_requested_qubit_order(): + probabilities = {"0": 0.1, "1": 0.2, "4": 0.3, "5": 0.4} + + assert marginal(probabilities, [0, 2], 3) == { + "00": 0.1, + "10": 0.2, + "01": 0.3, + "11": 0.4, + } + + +def test_marginal_combines_probability_mass(): + assert marginal(BELL_PROBABILITIES, [0], 2) == {"0": 0.5, "1": 0.5} + + +def test_marginal_over_no_qubits_returns_total_probability(): + assert marginal(BELL_PROBABILITIES, [], 2) == {"": 1.0} + + +def test_marginal_rejects_duplicate_qubits(): + with pytest.raises(ValueError, match="duplicates"): + marginal(BELL_PROBABILITIES, [0, 0], 2) + + +def test_marginal_rejects_qubit_outside_register(): + with pytest.raises(ValueError, match="outside the range"): + marginal(BELL_PROBABILITIES, [2], 2) + + +def test_expectation_z_for_bell_state_is_one(): + assert expectation_z(BELL_PROBABILITIES, 2) == 1.0 + + +def test_expectation_z_uses_parity_sign(): + assert expectation_z({"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.4}, 2) == pytest.approx(0.0) + + +def test_expectation_z_rejects_state_outside_register(): + with pytest.raises(ValueError, match="does not fit"): + expectation_z({"8": 1.0}, 3)