diff --git a/AGENTS.md b/AGENTS.md index eb67dd5..5ab0ed6 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/CHANGELOG.md b/CHANGELOG.md index 706ed79..42187ea 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` module with pure-Python helpers over the probability mappings 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). NumPy-free, 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 `ApiCostModel` (`"QCT"` or `"2QGE_operations"`). - `clone_job` endpoint (`POST /jobs/{UUID}/clone`) and its `CloneJobPayload` model for resubmitting an existing job with optional overrides. 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..fe2c1c8 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,219 @@ +# 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 + ``` +""" + +from __future__ import annotations + +__all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] + +import math +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. + + Args: + probabilities: State key (decimal-integer encoding) to probability. + All values must be finite and non-negative. + shots: Total number of shots to distribute. Must be non-negative. + + Returns: + State key to integer count. Counts sum to exactly ``shots``. + + Raises: + ValueError: If ``shots`` is negative or a probability is non-finite / negative. + + Examples: + ```python + >>> probabilities_to_counts({"0": 0.5, "3": 0.5}, 100) + {'0': 50, '3': 50} + >>> probabilities_to_counts({"0": 1/3, "1": 1/3, "2": 1/3}, 10) + {'0': 4, '1': 3, '2': 3} + ``` + """ + if shots < 0: + raise ValueError("shots must be non-negative") + + _validate_probabilities(probabilities) + + 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])), int(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. + + 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: State key (decimal-integer encoding) to probability. + num_qubits: Number of qubits; sets the bitstring width. Must be non-negative. + + Returns: + 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. + + Examples: + ```python + >>> relabel_to_bitstrings({"0": 0.5, "3": 0.5}, 2) + {'00': 0.5, '11': 0.5} + >>> relabel_to_bitstrings({"5": 1.0}, 4) + {'0101': 1.0} + ``` + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + + _validate_probabilities(probabilities) + 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). + + An empty ``qubits`` sequence marginalizes over every qubit and returns + ``{"": total_probability}``. + + Args: + probabilities: State key (decimal-integer encoding) to probability. + qubits: Qubits to keep, each in ``range(num_qubits)``. May be empty. + num_qubits: Total number of qubits. Must be non-negative. + + Returns: + Sub-bitstring over ``qubits`` to summed probability. + + Raises: + ValueError: If ``num_qubits`` is negative, a qubit is out of range, qubits contain + duplicates, or a state key does not fit in ``num_qubits`` qubits. + + Examples: + ```python + >>> marginal({"0": 0.5, "3": 0.5}, [0], 2) + {'0': 0.5, '1': 0.5} + ``` + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + if len(set(qubits)) != len(qubits): + raise ValueError("qubits must not contain duplicates") + for qubit in qubits: + if not 0 <= qubit < num_qubits: + raise ValueError(f"qubit {qubit} out of range for {num_qubits} qubits") + + _validate_probabilities(probabilities) + 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 qubits) + 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``. + + Args: + probabilities: State key (decimal-integer encoding) to probability. + num_qubits: Number of qubits. Must be non-negative. + + Returns: + 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. + + Examples: + ```python + >>> expectation_z({"0": 0.5, "3": 0.5}, 2) + 1.0 + >>> expectation_z({"0": 0.5, "1": 0.5}, 1) + 0.0 + ``` + """ + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") + + _validate_probabilities(probabilities) + 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 + + +def _validate_probabilities(probabilities: Mapping[str, float]) -> None: + """Validate that all probabilities are finite and non-negative.""" + for key, value in probabilities.items(): + if not math.isfinite(value) or value < 0: + raise ValueError(f"Probability for state '{key}' must be finite and non-negative, got {value}.") diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..7792f35 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,194 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ionq_core.results.""" + +from __future__ import annotations + +import math + +import pytest + +import ionq_core +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} + +# A 3-qubit GHZ state: only |000⟩ ("0") and |111⟩ ("7") appear. +GHZ = {"0": 0.5, "7": 0.5} + + +# ── re-export verification ────────────────────────────────────────── + + +class TestReExport: + def test_results_module_in_package_all(self): + assert "results" in ionq_core.__all__ + + def test_helpers_in_package_all(self): + for name in ("expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"): + assert name in ionq_core.__all__ + + def test_helpers_importable_from_package_root(self): + for name in ("expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"): + assert getattr(ionq_core, name) is getattr(ionq_core.results, name) + + +# ── probabilities_to_counts ───────────────────────────────────────── + + +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_exact_when_no_remainder(self): + assert probabilities_to_counts({"0": 0.2, "1": 0.3, "2": 0.5}, 10) == {"0": 2, "1": 3, "2": 5} + + 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) + + def test_non_finite_probability_raises(self): + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": math.nan}, 100) + + def test_infinite_probability_raises(self): + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": math.inf}, 100) + + def test_negative_probability_raises(self): + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": -0.1}, 100) + + +# ── relabel_to_bitstrings ─────────────────────────────────────────── + + +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_three_qubits(self): + assert relabel_to_bitstrings({"0": 0.25, "5": 0.75}, 3) == {"000": 0.25, "101": 0.75} + + 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) + + +# ── marginal ──────────────────────────────────────────────────────── + + +class TestMarginal: + def test_single_qubit_marginal(self): + assert marginal(BELL, [0], 2) == {"0": 0.5, "1": 0.5} + + def test_both_qubits_give_uniform(self): + assert marginal(BELL, [1], 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_ghz_subset(self): + # Keeping qubits 0 and 2 from a 3-qubit GHZ state. + res = marginal(GHZ, [0, 2], 3) + assert res == {"00": 0.5, "11": 0.5} + + def test_reversed_order(self): + assert marginal(BELL, [1, 0], 2) == {"00": 0.5, "11": 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_duplicate_qubits_raises(self): + with pytest.raises(ValueError, match="duplicate"): + marginal(BELL, [0, 0], 2) + + def test_qubit_out_of_range_raises(self): + with pytest.raises(ValueError, match="out of range"): + marginal(BELL, [2], 2) + + def test_negative_qubit_raises(self): + with pytest.raises(ValueError, match="out of range"): + marginal(BELL, [-1], 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) + + +# ── expectation_z ─────────────────────────────────────────────────── + + +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_asymmetric(self): + assert math.isclose(expectation_z({"0": 0.3, "1": 0.7}, 1), -0.4) + + def test_all_odd_parity(self): + """Every outcome has odd parity → ⟨Z⟩ = -1.""" + assert expectation_z({"1": 0.4, "2": 0.6}, 2) == -1.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)