diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..0e970a9 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 +- `ionq_core.results`, a NumPy-free module of pure-Python helpers over the probability mappings returned by the results endpoints: `probabilities_to_counts` (largest-remainder shot counts), `relabel_to_bitstrings` (integer state keys to zero-padded bitstrings), `marginal` (subset-of-qubits marginal), and `expectation_z` (all-qubit Z parity). The helpers follow IonQ's big-endian state-key convention (qubit 0 is the most significant bit) and are re-exported from the package root. - `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..cc3be40 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,226 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python helpers for post-processing IonQ probability results. + +The probability endpoints (`get_job_probabilities`, `get_variant_probabilities`, +`get_variant_histogram`) return a sparse mapping from measured basis state to +probability, exposed as `GetResultsResponse.additional_properties`. Each key is +the **integer encoding of the measured bitstring**, as a string, so `int(key)` +parses it. The helpers here operate on that plain `Mapping[str, float]`, so they +work for both the job and variant endpoints and are testable without HTTP. + +Bit-ordering convention +----------------------- +IonQ encodes state keys **big-endian**: qubit ``0`` is the *most* significant +bit of the integer, and the last measured qubit is the least significant bit. +So for an ``n``-qubit register the integer ``key`` corresponds to the bitstring +``format(key, f"0{n}b")``, read left-to-right as qubits ``0, 1, ..., n - 1``. +For example, on three qubits the integer ``2`` is ``"010"`` -- qubit ``1`` is +the only one measured in state ``1``. Every helper below follows this +convention. + +This module is intentionally NumPy-free (`math` and the standard library only), +matching `ionq_core.gates`. + +Example: + ```python + from ionq_core import probabilities_to_counts, expectation_z + from ionq_core.api.default import get_job_probabilities + + probs = get_job_probabilities.sync(uuid, client=client).additional_properties + probabilities_to_counts(probs, shots=1000) # {"0": 500, "3": 500} + expectation_z(probs, num_qubits=2) # 1.0 for a Bell state + ``` +""" + +from __future__ import annotations + +__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 a probability mapping to integer shot counts. + + Counts are assigned with the largest-remainder method, so they always sum + exactly to ``shots`` even when ``probability * shots`` is fractional. Ties on + the fractional remainder are broken by ascending integer state value, keeping + the result deterministic. + + Args: + probabilities: Mapping from integer-encoded state keys to probabilities. + Probabilities must be finite and non-negative and sum to 1 within a + small floating-point tolerance. + shots: Number of shots to distribute. Must be non-negative. + + Returns: + A mapping with the same keys as ``probabilities`` and integer counts that + sum to ``shots``. + + Raises: + ValueError: If ``shots`` is negative, a probability is invalid, the + probabilities do not sum to 1, or the mapping is empty while + ``shots`` is positive. + """ + if shots < 0: + raise ValueError("shots must be non-negative") + + floors: dict[str, int] = {} + remainders: list[tuple[float, int, str]] = [] + total = 0.0 + for key, probability in probabilities.items(): + probability = _validate_probability(probability, key) + total += probability + state = _parse_state_key(key) + exact = probability * shots + floor = math.floor(exact) + floors[key] = floor + remainders.append((exact - floor, state, key)) + + if not floors: + if shots == 0: + return {} + raise ValueError("probabilities must not be empty when shots is positive") + + if not math.isclose(total, 1.0, rel_tol=1e-9, abs_tol=1e-9): + raise ValueError("probabilities must sum to 1") + + leftover = shots - sum(floors.values()) + for _, _, key in sorted(remainders, key=lambda item: (-item[0], item[1]))[:leftover]: + 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 big-endian bitstrings. + + The bitstring is read left-to-right as qubits ``0, 1, ..., num_qubits - 1`` + (qubit ``0`` most significant), matching IonQ's wire convention. + + Args: + probabilities: Mapping from integer-encoded state keys to probabilities. + num_qubits: Width of the measured register; must be at least 1. + + Returns: + A mapping from ``num_qubits``-wide bitstrings to probabilities. + + Raises: + ValueError: If ``num_qubits`` is less than 1, or a state key is not an + integer within the register, or a probability is invalid. + """ + _validate_num_qubits(num_qubits) + relabeled: dict[str, float] = {} + for key, probability in probabilities.items(): + probability = _validate_probability(probability, key) + state = _parse_state_key(key, num_qubits) + relabeled[format(state, f"0{num_qubits}b")] = probability + return relabeled + + +def marginal(probabilities: Mapping[str, float], qubits: Iterable[int], num_qubits: int) -> dict[str, float]: + """Marginalize the distribution onto a subset of qubits. + + Probability mass is summed over the qubits that are dropped. Result keys are + integer-encoded over the kept qubits, in the order given by ``qubits``: the + first entry of ``qubits`` becomes the most significant bit of the reduced + key, consistent with the big-endian convention used throughout this module. + + Args: + probabilities: Mapping from integer-encoded state keys to probabilities. + qubits: Qubit indices to keep, in output order. Must be distinct and + within ``range(num_qubits)``. + num_qubits: Width of the full measured register; must be at least 1. + + Returns: + A probability mapping over the kept qubits, keyed by integer-encoded + reduced state. + + Raises: + ValueError: If ``num_qubits`` is less than 1, a kept qubit is out of + range or repeated, a state key is invalid, or a probability is + invalid. + """ + kept = _normalize_qubits(qubits, num_qubits) + reduced: dict[str, float] = {} + for key, probability in probabilities.items(): + probability = _validate_probability(probability, key) + state = _parse_state_key(key, num_qubits) + reduced_key = str(_project_state(state, kept, num_qubits)) + reduced[reduced_key] = reduced.get(reduced_key, 0.0) + probability + return reduced + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """Compute the all-qubit Pauli-``Z`` parity expectation value. + + Returns ``Sum_x p(x) * (-1) ** popcount(x)`` -- the expectation of + ``Z ⊗ Z ⊗ ... ⊗ Z`` over every measured qubit. The result lies in + ``[-1, 1]``; a Bell state gives ``1.0``. + + Args: + probabilities: Mapping from integer-encoded state keys to probabilities. + num_qubits: Width of the measured register; must be at least 1. + + Returns: + The parity expectation value. + + Raises: + ValueError: If ``num_qubits`` is less than 1, a state key is invalid, or + a probability is invalid. + """ + _validate_num_qubits(num_qubits) + expectation = 0.0 + for key, probability in probabilities.items(): + probability = _validate_probability(probability, key) + state = _parse_state_key(key, num_qubits) + expectation += probability if state.bit_count() % 2 == 0 else -probability + return expectation + + +def _normalize_qubits(qubits: Iterable[int], num_qubits: int) -> tuple[int, ...]: + _validate_num_qubits(num_qubits) + kept = tuple(qubits) + seen: set[int] = set() + for qubit in kept: + if qubit < 0 or qubit >= num_qubits: + raise ValueError(f"qubit {qubit} is outside the {num_qubits}-qubit register") + if qubit in seen: + raise ValueError(f"qubit {qubit} is repeated") + seen.add(qubit) + return kept + + +def _project_state(state: int, qubits: tuple[int, ...], num_qubits: int) -> int: + reduced = 0 + for output_index, qubit in enumerate(qubits): + bit = (state >> (num_qubits - 1 - qubit)) & 1 + reduced |= bit << (len(qubits) - 1 - output_index) + return reduced + + +def _parse_state_key(key: str, num_qubits: int | None = None) -> int: + try: + state = int(key) + except ValueError as exc: + raise ValueError(f"state key {key!r} is not an integer") from exc + if state < 0: + raise ValueError(f"state key {key!r} must be non-negative") + if num_qubits is not None and state >= 1 << num_qubits: + raise ValueError(f"state key {key!r} is outside the {num_qubits}-qubit register") + return state + + +def _validate_num_qubits(num_qubits: int) -> None: + if num_qubits < 1: + raise ValueError("num_qubits must be at least 1") + + +def _validate_probability(probability: float, key: str) -> float: + if not math.isfinite(probability): + raise ValueError(f"probability for state key {key!r} must be finite") + if probability < 0: + raise ValueError(f"probability for state key {key!r} must be non-negative") + return probability diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..cb82e14 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,162 @@ +import math + +import pytest + +import ionq_core +from ionq_core import expectation_z, marginal, probabilities_to_counts, relabel_to_bitstrings +from ionq_core.models.get_results_response import GetResultsResponse + +# Real simulator response for the two-qubit Bell circuit +# (h q0; cnot q0 q1) returned by GET /jobs/{uuid}/results/probabilities. +# Keys are big-endian integer-encoded states: "0" -> |00>, "3" -> |11>. +BELL_RESPONSE = GetResultsResponse.from_dict({"0": 0.5, "3": 0.5}) +BELL_PROBABILITIES = BELL_RESPONSE.additional_properties + +# Real simulator response for a three-qubit GHZ circuit +# (h q0; cnot q0 q1; cnot q1 q2): only |000> ("0") and |111> ("7") appear. +GHZ_PROBABILITIES = {"0": 0.5, "7": 0.5} + + +def test_helpers_reexported_from_package_root(): + assert "results" in ionq_core.__all__ + for name in ("expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"): + assert name in ionq_core.__all__ + assert getattr(ionq_core, name) is getattr(ionq_core.results, name) + + +def test_relabel_bell_to_bitstrings(): + assert relabel_to_bitstrings(BELL_PROBABILITIES, 2) == {"00": 0.5, "11": 0.5} + + +def test_relabel_is_big_endian_qubit_zero_is_most_significant(): + # Integer 2 on three qubits is "010": only qubit 1 (the middle, big-endian) is set. + assert relabel_to_bitstrings({"2": 1.0}, 3) == {"010": 1.0} + + +def test_relabel_rejects_out_of_range_state(): + with pytest.raises(ValueError, match="outside"): + relabel_to_bitstrings({"4": 1.0}, 2) + + +def test_relabel_rejects_non_integer_state(): + with pytest.raises(ValueError, match="not an integer"): + relabel_to_bitstrings({"zero": 1.0}, 2) + + +def test_relabel_rejects_invalid_num_qubits(): + with pytest.raises(ValueError, match="at least 1"): + relabel_to_bitstrings(BELL_PROBABILITIES, 0) + + +def test_probabilities_to_counts_bell(): + counts = probabilities_to_counts(BELL_PROBABILITIES, 1000) + assert counts == {"0": 500, "3": 500} + assert sum(counts.values()) == 1000 + + +def test_probabilities_to_counts_largest_remainder_distributes_leftover(): + counts = probabilities_to_counts({"0": 1 / 3, "1": 1 / 3, "2": 1 / 3}, 10) + assert sum(counts.values()) == 10 + # All three remainders tie; ascending state value wins the single leftover. + assert counts == {"0": 4, "1": 3, "2": 3} + + +def test_probabilities_to_counts_exact_when_no_remainder(): + assert probabilities_to_counts({"0": 0.2, "1": 0.3, "2": 0.5}, 10) == {"0": 2, "1": 3, "2": 5} + + +def test_probabilities_to_counts_zero_shots(): + assert probabilities_to_counts(BELL_PROBABILITIES, 0) == {"0": 0, "3": 0} + + +def test_probabilities_to_counts_empty_with_zero_shots(): + assert probabilities_to_counts({}, 0) == {} + + +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_empty_with_positive_shots(): + with pytest.raises(ValueError, match="must not be empty"): + probabilities_to_counts({}, 100) + + +def test_probabilities_to_counts_rejects_non_normalized(): + with pytest.raises(ValueError, match="sum to 1"): + probabilities_to_counts({"0": 0.4, "1": 0.5}, 100) + + +def test_probabilities_to_counts_rejects_non_finite_probability(): + with pytest.raises(ValueError, match="must be finite"): + probabilities_to_counts({"0": math.inf}, 100) + + +def test_probabilities_to_counts_rejects_negative_probability(): + with pytest.raises(ValueError, match="must be non-negative"): + probabilities_to_counts({"0": -0.1, "1": 1.1}, 100) + + +def test_probabilities_to_counts_rejects_non_integer_state(): + with pytest.raises(ValueError, match="not an integer"): + probabilities_to_counts({"oops": 1.0}, 100) + + +def test_marginal_drops_qubit_and_sums_mass(): + # GHZ marginalized onto qubit 0 keeps perfect correlation: half |0>, half |1>. + assert marginal(GHZ_PROBABILITIES, [0], 3) == {"0": 0.5, "1": 0.5} + + +def test_marginal_collapses_when_qubit_independent_of_state(): + # Marginalizing the Bell distribution onto qubit 0 alone: "0"->|0..>, "3"->|1..>. + assert marginal(BELL_PROBABILITIES, [0], 2) == {"0": 0.5, "1": 0.5} + + +def test_marginal_respects_requested_qubit_order(): + # State "1" on three qubits is "001" (only qubit 2 set, big-endian). + # Keeping [2, 0] big-endian: qubit 2 -> bit1, qubit 0 -> bit0 => "10" == 2. + assert marginal({"1": 1.0}, [2, 0], 3) == {"2": 1.0} + + +def test_marginal_with_empty_qubit_selection_returns_total_mass(): + assert marginal(BELL_PROBABILITIES, [], 2) == {"0": 1.0} + + +def test_marginal_rejects_out_of_range_qubit(): + with pytest.raises(ValueError, match="outside"): + marginal(BELL_PROBABILITIES, [2], 2) + + +def test_marginal_rejects_repeated_qubit(): + with pytest.raises(ValueError, match="repeated"): + marginal(BELL_PROBABILITIES, [0, 0], 2) + + +def test_marginal_rejects_invalid_num_qubits(): + with pytest.raises(ValueError, match="at least 1"): + marginal(BELL_PROBABILITIES, [0], 0) + + +def test_marginal_rejects_invalid_probability(): + with pytest.raises(ValueError, match="must be non-negative"): + marginal({"0": -0.5, "1": 1.5}, [0], 1) + + +def test_expectation_z_bell_is_plus_one(): + assert expectation_z(BELL_PROBABILITIES, 2) == 1.0 + + +def test_expectation_z_odd_parity_contributes_negative(): + # "1" (|01>) and "2" (|10>) have odd parity; "3" (|11>) is even. + assert expectation_z({"1": 0.25, "2": 0.25, "3": 0.5}, 2) == 0.0 + + +def test_expectation_z_rejects_invalid_num_qubits(): + with pytest.raises(ValueError, match="at least 1"): + expectation_z(BELL_PROBABILITIES, 0) + + +def test_expectation_z_rejects_negative_state_key(): + with pytest.raises(ValueError, match="must be non-negative"): + expectation_z({"-1": 1.0}, 2)