diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..6cde351 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 result post-processing helpers for converting probabilities to counts, bitstring labels, marginals, and all-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..6ea6e20 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,133 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python helpers for post-processing IonQ probability results. + +IonQ probability endpoints expose mappings whose keys are integer-encoded +measurement states and whose values are probabilities. The helpers here keep +that wire-level shape, but make common SDK tasks easier: deterministic shot +rounding, bitstring labels, marginals, and all-Z parity expectation values. + +Bit-ordering convention: + Integer state keys are interpreted as computational-basis integers. Qubit 0 + is the least-significant bit, so the two-qubit Bell-state probabilities + ``{"0": 0.5, "3": 0.5}`` relabel to ``{"00": 0.5, "11": 0.5}``. +""" + +from collections.abc import Iterable, Mapping +from math import floor + +__all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] + + +def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]: + """Convert probabilities to integer shot counts using largest-remainder rounding. + + The returned counts always sum exactly to ``shots``. Ties are broken by the + integer value of the result key, keeping output deterministic for callers and + tests. + + Args: + probabilities: IonQ probability mapping, keyed by integer state strings. + shots: Total number of requested shots. Must be non-negative. + + Returns: + A mapping with the same keys and integer counts summing to ``shots``. + """ + if shots < 0: + raise ValueError("shots must be non-negative") + + floors: dict[str, int] = {} + remainders: list[tuple[float, int, str]] = [] + for key, probability in probabilities.items(): + expected = probability * shots + count = floor(expected) + floors[key] = count + remainders.append((expected - count, _state_int(key), key)) + + missing = shots - sum(floors.values()) + for _, _, key in sorted(remainders, key=lambda item: (-item[0], item[1], item[2]))[:missing]: + floors[key] += 1 + + return floors + + +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Relabel integer state keys to zero-padded computational-basis bitstrings. + + Args: + probabilities: IonQ probability mapping, keyed by integer state strings. + num_qubits: Width of the output bitstrings. + + Returns: + Probabilities keyed by zero-padded bitstrings of length ``num_qubits``. + """ + _validate_num_qubits(num_qubits) + bitstring_probabilities: dict[str, float] = {} + for key, probability in probabilities.items(): + bitstring = _bitstring(_state_int(key), num_qubits) + bitstring_probabilities[bitstring] = bitstring_probabilities.get(bitstring, 0.0) + probability + return bitstring_probabilities + + +def marginal(probabilities: Mapping[str, float], qubits: Iterable[int], num_qubits: int) -> dict[str, float]: + """Marginalize probabilities onto the requested qubits. + + Qubit indices follow the integer-key convention: qubit 0 is the + least-significant bit. The output bitstring follows the order in ``qubits``. + + Args: + probabilities: IonQ probability mapping, keyed by integer state strings. + qubits: Qubit indices to keep, in output order. + num_qubits: Width of the full result register. + + Returns: + Marginal probabilities keyed by selected-qubit bitstrings. + """ + _validate_num_qubits(num_qubits) + selected = tuple(qubits) + for qubit in selected: + if qubit < 0 or qubit >= num_qubits: + raise ValueError("qubits must be within the result register") + + result: dict[str, float] = {} + for key, probability in probabilities.items(): + state = _state_int(key) + label = "".join(str((state >> qubit) & 1) for qubit in selected) + result[label] = result.get(label, 0.0) + probability + return result + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """Compute the all-Z parity expectation value for a probability mapping. + + The value is ``sum(probability * (-1) ** popcount(state))`` over the + provided result states. + + Args: + probabilities: IonQ probability mapping, keyed by integer state strings. + num_qubits: Width of the result register. + + Returns: + The all-Z expectation value. + """ + _validate_num_qubits(num_qubits) + return sum( + probability * (-1 if _state_int(key).bit_count() % 2 else 1) for key, probability in probabilities.items() + ) + + +def _validate_num_qubits(num_qubits: int) -> None: + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + + +def _state_int(key: str) -> int: + state = int(key) + if state < 0: + raise ValueError("state keys must be non-negative integers") + return state + + +def _bitstring(state: int, num_qubits: int) -> str: + return f"{state:0{num_qubits}b}" diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..13c679a --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +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_bell_state_rounds_to_exact_shots(): + assert probabilities_to_counts(BELL_PROBABILITIES, 100) == {"0": 50, "3": 50} + + +def test_probabilities_to_counts_uses_largest_remainder_with_stable_ties(): + probabilities = {"0": 0.333, "1": 0.333, "2": 0.334} + + assert probabilities_to_counts(probabilities, 10) == {"0": 3, "1": 3, "2": 4} + assert probabilities_to_counts({"2": 0.5, "1": 0.5}, 1) == {"2": 0, "1": 1} + + +def test_probabilities_to_counts_rejects_negative_shots(): + with pytest.raises(ValueError, match="shots"): + probabilities_to_counts(BELL_PROBABILITIES, -1) + + +def test_relabel_to_bitstrings_zero_pads_and_sums_duplicate_integer_labels(): + probabilities = {"0": 0.25, "01": 0.25, "3": 0.5} + + assert relabel_to_bitstrings(probabilities, 2) == {"00": 0.25, "01": 0.25, "11": 0.5} + + +def test_relabel_to_bitstrings_rejects_invalid_register_width(): + with pytest.raises(ValueError, match="num_qubits"): + relabel_to_bitstrings(BELL_PROBABILITIES, -1) + + +def test_marginal_uses_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} + assert marginal(BELL_PROBABILITIES, [], 2) == {"": 1.0} + + +def test_marginal_rejects_out_of_range_qubits(): + with pytest.raises(ValueError, match="qubits"): + marginal(BELL_PROBABILITIES, [2], 2) + with pytest.raises(ValueError, match="qubits"): + marginal(BELL_PROBABILITIES, [-1], 2) + + +def test_expectation_z_computes_all_z_parity(): + assert expectation_z(BELL_PROBABILITIES, 2) == 1.0 + assert expectation_z({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 2) == 0.0 + + +def test_helpers_reject_negative_state_keys(): + with pytest.raises(ValueError, match="state keys"): + expectation_z({"-1": 1.0}, 1)