From 7910adbbf9268fed1fd854a50ac35af4a5b97d22 Mon Sep 17 00:00:00 2001 From: Vinny010 Date: Wed, 17 Jun 2026 11:30:02 +0400 Subject: [PATCH] Add pure-Python results post-processing helpers Add ionq_core/results.py with probabilities_to_counts, relabel_to_bitstrings, marginal, and expectation_z over the register-keyed probability mapping returned by the results endpoints. Pure-Python and NumPy-free (like gates.py), operating on a plain Mapping[str, float] so they work for both the job and variant endpoints and are testable without HTTP. The bit-ordering convention (qubit 0 is the most-significant bit) is documented in the module docstring and applied consistently across the helpers. - Re-export from ionq_core via the package-init template. - tests/test_results.py with a Bell-state fixture and rounding edge cases; 100% branch coverage. - CHANGELOG entry under [Unreleased]. Closes #57 --- CHANGELOG.md | 1 + custom-templates/package_init.py.jinja | 2 +- ionq_core/__init__.py | 5 +- ionq_core/results.py | 191 +++++++++++++++++++++++++ tests/test_results.py | 109 ++++++++++++++ 5 files changed, 306 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..fc81023 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 +- `results` module with pure-Python helpers over the probabilities mapping returned by the results endpoints: `probabilities_to_counts` (largest-remainder integer counts), `relabel_to_bitstrings` (integer keys to zero-padded bitstrings), `marginal` (marginal over a subset of qubits), and `expectation_z` (all-qubit Pauli-Z expectation). Re-exported from `ionq_core`. - `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..a9bc44e --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python post-processing helpers for IonQ probability results. + +The results endpoints (``get_job_probabilities``, ``get_variant_probabilities``, +``get_variant_histogram``) return IonQ's register-keyed probability mapping as-is: a +``Mapping[str, float]`` whose keys are the **decimal-integer encodings** of the measured +computational-basis states and whose values are probabilities. For a 2-qubit Bell state the +mapping looks like ``{"0": 0.5, "3": 0.5}`` (states ``|00>`` and ``|11>``). + +These helpers cover the post-processing that downstream wrappers (``qiskit-ionq``, +``cirq-ionq``, ``pennylane-ionq``) would otherwise each re-implement. They are pure-Python and +NumPy-free (like :mod:`ionq_core.gates`), and operate on a plain ``Mapping[str, float]`` so they +work for both the job and variant endpoints and are testable without HTTP. + +Bit-ordering convention +----------------------- +A state key is the integer ``value`` of its bitstring. Throughout this module qubit ``q`` is the +bit of weight ``2 ** (num_qubits - 1 - q)``: qubit ``0`` is the **most-significant** bit and +appears **leftmost** in the zero-padded bitstring produced by :func:`relabel_to_bitstrings`. The +same convention is used to select qubits in :func:`marginal`. + +Example: + ```python + from ionq_core import expectation_z, marginal, probabilities_to_counts, relabel_to_bitstrings + + probs = {"0": 0.5, "3": 0.5} # 2-qubit Bell state + probabilities_to_counts(probs, shots=1000) # {"0": 500, "3": 500} + relabel_to_bitstrings(probs, num_qubits=2) # {"00": 0.5, "11": 0.5} + marginal(probs, qubits=[0], num_qubits=2) # {"0": 0.5, "1": 0.5} + expectation_z(probs, num_qubits=2) # 1.0 + ``` +""" + +__all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] + +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 shot counts summing exactly to ``shots``. + + Uses the largest-remainder (Hamilton) method: each count is first floored, then the leftover + shots are handed out one at a time to the states with the largest fractional parts, breaking + ties by ascending state key for determinism. The returned counts therefore sum to exactly + ``shots`` whenever the probabilities are normalized. + + Parameters + ---------- + probabilities : Mapping[str, float] + State key (decimal-integer encoding) to probability. + shots : int + Total number of shots to distribute. Must be non-negative. + + Returns + ------- + dict[str, int] + State key to integer count, in the iteration order of ``probabilities``. + + Raises + ------ + ValueError + If ``shots`` is negative. + """ + if shots < 0: + raise ValueError("shots must be non-negative") + scaled = {key: probability * shots for key, probability in probabilities.items()} + counts = {key: int(value) for key, value in scaled.items()} + remainder = shots - sum(counts.values()) + if remainder: + ranked = sorted(scaled, key=lambda key: (-(scaled[key] - int(scaled[key])), key)) + for key in ranked[:remainder]: + counts[key] += 1 + return counts + + +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Relabel integer state keys to zero-padded bitstrings. + + Parameters + ---------- + probabilities : Mapping[str, float] + State key (decimal-integer encoding) to probability. + num_qubits : int + Number of qubits; sets the bitstring width. Must be non-negative. + + Returns + ------- + dict[str, float] + Zero-padded bitstring (qubit ``0`` leftmost) to probability. + + Raises + ------ + ValueError + If ``num_qubits`` is negative, or a state key does not fit in ``num_qubits`` qubits. + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + bound = 1 << num_qubits + result: dict[str, float] = {} + for key, probability in probabilities.items(): + value = int(key) + if not 0 <= value < bound: + raise ValueError(f"state key {key!r} does not fit in {num_qubits} qubits") + result[format(value, f"0{num_qubits}b")] = probability + return result + + +def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]: + """Marginalize a probability mapping over a subset of qubits. + + Probabilities are summed over all states sharing the same values on the selected ``qubits``. + The marginal keys are bitstrings over ``qubits`` in the order given (qubit ``0`` is the + most-significant bit of the full state; see the module docstring). + + Parameters + ---------- + probabilities : Mapping[str, float] + State key (decimal-integer encoding) to probability. + qubits : Sequence[int] + Qubits to keep, each in ``range(num_qubits)``. An empty sequence marginalizes over every + qubit and returns ``{"": total_probability}``. + num_qubits : int + Total number of qubits. Must be non-negative. + + Returns + ------- + dict[str, float] + Sub-bitstring over ``qubits`` to summed probability. + + Raises + ------ + ValueError + If ``num_qubits`` is negative, a qubit is out of range, or a state key does not fit in + ``num_qubits`` qubits. + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + selected = list(qubits) + for qubit in selected: + if not 0 <= qubit < num_qubits: + raise ValueError(f"qubit {qubit} out of range for {num_qubits} qubits") + bound = 1 << num_qubits + result: dict[str, float] = {} + for key, probability in probabilities.items(): + value = int(key) + if not 0 <= value < bound: + raise ValueError(f"state key {key!r} does not fit in {num_qubits} qubits") + bits = format(value, f"0{num_qubits}b") + sub = "".join(bits[qubit] for qubit in selected) + result[sub] = result.get(sub, 0.0) + probability + return result + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """Compute the expectation value of the all-qubit Pauli-Z operator (``Z`` on every qubit). + + This is the parity sum ``sum(p(x) * (-1) ** popcount(x))``: states with an even number of set + bits contribute ``+p`` and odd-parity states contribute ``-p``. + + Parameters + ---------- + probabilities : Mapping[str, float] + State key (decimal-integer encoding) to probability. + num_qubits : int + Number of qubits. Must be non-negative. + + Returns + ------- + float + The expectation value in ``[-1, 1]`` for a normalized distribution. + + Raises + ------ + ValueError + If ``num_qubits`` is negative, or a state key does not fit in ``num_qubits`` qubits. + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + bound = 1 << num_qubits + total = 0.0 + for key, probability in probabilities.items(): + value = int(key) + if not 0 <= value < bound: + raise ValueError(f"state key {key!r} does not fit in {num_qubits} qubits") + if bin(value).count("1") % 2: + total -= probability + else: + total += probability + return total diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..2861a1b --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,109 @@ +import pytest + +from ionq_core.results import ( + expectation_z, + marginal, + probabilities_to_counts, + relabel_to_bitstrings, +) + +# A 2-qubit Bell state, as the probabilities endpoints would return it: states |00> (key "0") +# and |11> (key "3") with equal probability. +BELL = {"0": 0.5, "3": 0.5} + + +class TestProbabilitiesToCounts: + def test_bell_state_exact_split(self): + assert probabilities_to_counts(BELL, 1000) == {"0": 500, "3": 500} + + def test_largest_remainder_distributes_leftover(self): + # Three equal outcomes over 10 shots: floors are 3, 3, 3 and the leftover shot goes to the + # largest remainder (a tie here, broken by the lowest state key). + probs = {"0": 1 / 3, "1": 1 / 3, "2": 1 / 3} + counts = probabilities_to_counts(probs, 10) + assert sum(counts.values()) == 10 + assert counts == {"0": 4, "1": 3, "2": 3} + + def test_tie_break_prefers_lowest_key(self): + # Both remainders are 0.5; the single leftover shot must go to key "1", not "2". + assert probabilities_to_counts({"2": 0.5, "1": 0.5}, 3) == {"2": 1, "1": 2} + + def test_zero_shots(self): + assert probabilities_to_counts(BELL, 0) == {"0": 0, "3": 0} + + def test_empty_mapping(self): + assert probabilities_to_counts({}, 100) == {} + + def test_negative_shots_raises(self): + with pytest.raises(ValueError, match="non-negative"): + probabilities_to_counts(BELL, -1) + + +class TestRelabelToBitstrings: + def test_bell_state(self): + assert relabel_to_bitstrings(BELL, 2) == {"00": 0.5, "11": 0.5} + + def test_zero_padding_width(self): + assert relabel_to_bitstrings({"5": 1.0}, 4) == {"0101": 1.0} + + def test_empty_mapping(self): + assert relabel_to_bitstrings({}, 3) == {} + + def test_key_out_of_range_raises(self): + with pytest.raises(ValueError, match="does not fit"): + relabel_to_bitstrings({"4": 1.0}, 2) + + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + relabel_to_bitstrings(BELL, -1) + + +class TestMarginal: + def test_single_qubit_marginal(self): + assert marginal(BELL, [0], 2) == {"0": 0.5, "1": 0.5} + + def test_accumulates_over_dropped_qubits(self): + # Qubit 0 is 0 for both |00> and |01>, so their probabilities are summed. + probs = {"0": 0.25, "1": 0.25, "3": 0.5} + assert marginal(probs, [0], 2) == {"0": 0.5, "1": 0.5} + + def test_empty_qubits_returns_total(self): + assert marginal(BELL, [], 2) == {"": 1.0} + + def test_empty_probabilities(self): + assert marginal({}, [0], 1) == {} + + def test_qubit_out_of_range_raises(self): + with pytest.raises(ValueError, match="out of range"): + marginal(BELL, [2], 2) + + def test_key_out_of_range_raises(self): + with pytest.raises(ValueError, match="does not fit"): + marginal({"9": 1.0}, [0], 2) + + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + marginal(BELL, [0], -1) + + +class TestExpectationZ: + def test_bell_state_is_plus_one(self): + assert expectation_z(BELL, 2) == pytest.approx(1.0) + + def test_odd_parity_state_is_negative(self): + # |01> (key "1") has odd parity and contributes -p. + assert expectation_z({"1": 1.0}, 2) == pytest.approx(-1.0) + + def test_opposite_parities_cancel(self): + assert expectation_z({"0": 0.5, "1": 0.5}, 2) == pytest.approx(0.0) + + def test_empty_mapping_is_zero(self): + assert expectation_z({}, 2) == 0.0 + + def test_key_out_of_range_raises(self): + with pytest.raises(ValueError, match="does not fit"): + expectation_z({"4": 1.0}, 2) + + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + expectation_z(BELL, -1)