From e3ee2744ee92d36a07b842d1cb6ce91c6ed3c43c Mon Sep 17 00:00:00 2001 From: RICH66668888 Date: Tue, 16 Jun 2026 10:03:33 +0800 Subject: [PATCH] feat: add pure-Python results post-processing helpers Add ionq_core/results.py with input validation, little_endian and drop_zeros support, full docstrings, and 100% branch coverage. --- AGENTS.md | 1 + CHANGELOG.md | 1 + custom-templates/package_init.py.jinja | 2 +- ionq_core/__init__.py | 5 +- ionq_core/results.py | 191 +++++++++++++++++++++++++ tests/test_results.py | 140 ++++++++++++++++++ 6 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 ionq_core/results.py create mode 100644 tests/test_results.py diff --git a/AGENTS.md b/AGENTS.md index a6ad7d2..e17028a 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 c655fdb..ebe6ca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - `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"`). +- `ionq_core.results` module with pure-Python result post-processing helpers: `probabilities_to_counts`, `relabel_to_bitstrings`, `marginal`, and `expectation_z`. ### Changed 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..5497e93 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Pure-Python helpers for IonQ probability mappings — no NumPy, no surprises. + +IonQ result endpoints return state-key → probability dicts where +qubit 0 is the least-significant bit of the integer key (e.g. a +two-qubit Bell state appears as ``{"0": 0.5, "3": 0.5}``). +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence + +__all__ = [ + "expectation_z", + "marginal", + "probabilities_to_counts", + "relabel_to_bitstrings", +] + + +def _check(probabilities: Mapping[str, float]) -> None: + """Validate that all probabilities are finite and non-negative.""" + for k, v in probabilities.items(): + if not math.isfinite(v) or v < 0: + raise ValueError(f"Probability for state '{k}' must be finite and non-negative, got {v}.") + + +def probabilities_to_counts( + probabilities: Mapping[str, float], + shots: int, + *, + drop_zeros: bool = True, +) -> dict[str, int]: + """Convert a probability mapping to integer counts. + + Uses largest-remainder rounding so the result sums exactly to ``shots``. + + Args: + probabilities: Mapping from integer state keys to probabilities. + shots: Total number of shots. Must be non-negative. + drop_zeros: If True (default), omit states with zero counts. + + Returns: + Mapping from state keys to integer counts, summing to ``shots``. + + Raises: + ValueError: If ``shots`` is negative or any probability is non-finite. + + Example:: + + probabilities_to_counts({"0": 0.5, "3": 0.5}, 100) + # → {'0': 50, '3': 50} + """ + if shots < 0: + raise ValueError(f"shots must be non-negative, got {shots}.") + if not probabilities or shots == 0: + return {} + _check(probabilities) + + floors = {} + remainders = {} + for k, v in probabilities.items(): + exact = v * shots + f = math.floor(exact) + floors[k] = f + remainders[k] = exact - f + + remaining = shots - sum(floors.values()) + if remaining: + for k in sorted(remainders, key=remainders.get, reverse=True)[:remaining]: + floors[k] += 1 + + if drop_zeros: + return {k: v for k, v in floors.items() if v} + return floors + + +def relabel_to_bitstrings( + probabilities: Mapping[str, float], + num_qubits: int, + *, + little_endian: bool = False, +) -> dict[str, float]: + """Relabel integer state keys to zero-padded bitstrings. + + Args: + probabilities: Mapping from integer state keys to probabilities. + num_qubits: Number of qubits to pad to. + little_endian: If True, qubit 0 appears on the left (reversed). + + Returns: + Mapping from bitstring keys to the same probabilities. + + Raises: + ValueError: If any state key exceeds the range of ``num_qubits``. + + Example:: + + relabel_to_bitstrings({"0": 0.5, "3": 0.5}, 2) + # → {'00': 0.5, '11': 0.5} + + relabel_to_bitstrings({"0": 0.5, "3": 0.5}, 2, little_endian=True) + # → {'00': 0.5, '11': 0.5} + """ + max_state = (1 << num_qubits) - 1 + result = {} + for key, prob in probabilities.items(): + s = int(key) + if not 0 <= s <= max_state: + raise ValueError(f"State {s} out of bounds for {num_qubits} qubits (max {max_state}).") + bs = f"{s:0{num_qubits}b}" + result[bs[::-1] if little_endian else bs] = prob + return result + + +def marginal( + probabilities: Mapping[str, float], + qubits: Sequence[int], + num_qubits: int, +) -> dict[str, float]: + """Marginal probability distribution over a subset of qubits. + + ``qubits[0]`` is the most significant position in the output key. + + Args: + probabilities: Mapping from integer state keys to probabilities. + qubits: Qubit indices to keep (qubit 0 is the LSB). + num_qubits: Total qubits in the input distribution. + + Returns: + Mapping from output integer keys to marginal probabilities. + + Raises: + ValueError: If ``qubits`` is empty, has duplicates, or has out-of-range indices. + + Example:: + + marginal({"0": 0.5, "3": 0.5}, [0], 2) + # → {'0': 0.5, '1': 0.5} + """ + if not qubits: + raise ValueError("qubits must not be empty.") + if len(set(qubits)) != len(qubits): + raise ValueError("qubits must not contain duplicates.") + for q in qubits: + if q < 0 or q >= num_qubits: + raise ValueError(f"Qubit index {q} out of bounds for {num_qubits} qubits.") + + n = len(qubits) + result: dict[str, float] = {} + for key, prob in probabilities.items(): + s = int(key) + out = 0 + for i, q in enumerate(qubits): + out |= ((s >> q) & 1) << (n - 1 - i) + sk = str(out) + result[sk] = result.get(sk, 0.0) + prob + return result + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + r"""⟨Z⊗⋯⊗Z⟩ — parity expectation value over all measured qubits. + + States with even popcount contribute +1.p; odd popcount -1.p. + + Args: + probabilities: Mapping from integer state keys to probabilities. + num_qubits: Total number of qubits. + + Returns: + The Z-parity expectation value in [-1, +1]. + + Raises: + ValueError: If any state key exceeds the range of ``num_qubits``. + + Example:: + + expectation_z({"0": 0.5, "3": 0.5}, 2) + # → 1.0 + """ + max_state = (1 << num_qubits) - 1 + total = 0.0 + for key, prob in probabilities.items(): + s = int(key) + if not 0 <= s <= max_state: + raise ValueError(f"State {s} out of bounds for {num_qubits} qubits (max {max_state}).") + total += prob if s.bit_count() % 2 == 0 else -prob + return total diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..6ad4317 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ionq_core.results.""" + +from __future__ import annotations + +import math + +import pytest + +from ionq_core.results import ( + expectation_z, + marginal, + probabilities_to_counts, + relabel_to_bitstrings, +) + +BELL = {"0": 0.5, "3": 0.5} + + +# ── probabilities_to_counts ────────────────────────────────────────── + + +class TestProbabilitiesToCounts: + def test_round_numbers(self): + """Even split gives exact integer counts.""" + assert probabilities_to_counts(BELL, 1000) == {"0": 500, "3": 500} + + def test_largest_remainder(self): + c = probabilities_to_counts({"0": 0.333, "1": 0.333, "2": 0.334}, 1000) + assert c["2"] == 334 + assert sum(c.values()) == 1000 + + def test_tied_remainder(self): + c = probabilities_to_counts({"0": 0.5, "1": 0.5}, 1, drop_zeros=False) + assert sorted(c.values()) == [0, 1] + + def test_empty_or_zero_shots(self): + assert probabilities_to_counts({}, 1000) == {} + assert probabilities_to_counts(BELL, 0) == {} + + def test_negative_shots(self): + with pytest.raises(ValueError, match="shots must be non-negative"): + probabilities_to_counts(BELL, -1) + + def test_drop_zeros_false(self): + c = probabilities_to_counts({"0": 1.0}, 100, drop_zeros=False) + assert "0" in c and c["0"] == 100 + + def test_invalid_probability(self): + with pytest.raises(ValueError, match=r"finite and non.negative"): + probabilities_to_counts({"0": math.nan}, 100) + with pytest.raises(ValueError, match=r"finite and non.negative"): + probabilities_to_counts({"0": math.inf}, 100) + with pytest.raises(ValueError, match=r"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_three_qubits(self): + assert relabel_to_bitstrings({"0": 0.25, "5": 0.75}, 3) == { + "000": 0.25, + "101": 0.75, + } + + def test_little_endian(self): + """qubit 0 appears on the left when little_endian=True.""" + assert relabel_to_bitstrings({"1": 1.0}, 2, little_endian=True) == {"10": 1.0} + + def test_little_endian_bell(self): + """Bell state: key 3 (0b11) → '11' either way (symmetric).""" + assert relabel_to_bitstrings(BELL, 2, little_endian=True) == {"00": 0.5, "11": 0.5} + + def test_out_of_bounds(self): + with pytest.raises(ValueError, match="out of bounds"): + relabel_to_bitstrings({"4": 1.0}, 2) + + +# ── marginal ───────────────────────────────────────────────────────── + + +class TestMarginal: + def test_bell_single_qubit(self): + assert marginal(BELL, [0], 2) == {"0": 0.5, "1": 0.5} + assert marginal(BELL, [1], 2) == {"0": 0.5, "1": 0.5} + + def test_reversed_order(self): + assert marginal(BELL, [1, 0], 2) == {"0": 0.5, "3": 0.5} + + def test_accumulates(self): + assert marginal({"1": 0.3, "2": 0.7}, [0], 2) == {"1": 0.3, "0": 0.7} + + def test_empty_qubits(self): + with pytest.raises(ValueError, match="qubits must not be empty"): + marginal(BELL, [], 2) + + def test_duplicate_qubits(self): + with pytest.raises(ValueError, match="duplicate"): + marginal(BELL, [0, 0], 2) + + def test_out_of_range_qubit(self): + with pytest.raises(ValueError, match="out of bounds"): + marginal(BELL, [2], 2) + with pytest.raises(ValueError, match="out of bounds"): + marginal(BELL, [-1], 2) + + +# ── expectation_z ──────────────────────────────────────────────────── + + +class TestExpectationZ: + def test_bell_state(self): + """Both outcomes even parity → ⟨Z⟩ = +1.""" + assert expectation_z(BELL, 2) == 1.0 + + def test_even_superposition(self): + """|+⟩ → ⟨Z⟩ = 0.""" + assert expectation_z({"0": 0.5, "1": 0.5}, 1) == 0.0 + + def test_pure_states(self): + assert expectation_z({"0": 1.0}, 1) == 1.0 + assert expectation_z({"1": 1.0}, 1) == -1.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_out_of_bounds(self): + with pytest.raises(ValueError, match="out of bounds"): + expectation_z({"4": 1.0}, 2)