From 7d33043b2a59a208f47083676a435e81955973f4 Mon Sep 17 00:00:00 2001 From: scarwizz Date: Tue, 9 Jun 2026 23:53:15 +0530 Subject: [PATCH 1/2] feat: add pure-Python results post-processing helpers --- ionq_core/results.py | 121 ++++++++++++++++++++++++++++++++++++ tests/test_results.py | 140 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 ionq_core/results.py create mode 100644 tests/test_results.py diff --git a/ionq_core/results.py b/ionq_core/results.py new file mode 100644 index 0000000..4e6dcd4 --- /dev/null +++ b/ionq_core/results.py @@ -0,0 +1,121 @@ +""" +Pure-Python results post-processing helpers for IonQ's probability mappings. +""" + +import math +from collections.abc import Mapping, Sequence + +__all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] + + +def _validate_probabilities(probabilities: Mapping[str, float]) -> None: + """Validate that all probabilities are finite and non-negative.""" + for state, prob in probabilities.items(): + if not math.isfinite(prob) or prob < 0.0: + raise ValueError(f"Probability for state '{state}' must be finite and non-negative, got {prob}.") + + +def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]: + """ + Convert a probability mapping to exact integer counts summing to `shots`. + + Uses the largest-remainder method (Hare quota) to handle floating-point + rounding errors and guarantee the final counts sum perfectly to `shots`. + """ + if shots < 1: + raise ValueError(f"Shots must be at least 1, got {shots}.") + + _validate_probabilities(probabilities) + + base_counts = {} + remainders = {} + + for state, prob in probabilities.items(): + exact = prob * shots + base = math.floor(exact) + base_counts[state] = base + remainders[state] = exact - base + + shortfall = shots - sum(base_counts.values()) + + # Sort by remainder descending. + # Tie-breaker: sort by integer state ascending to make it deterministic. + sorted_states = sorted(remainders.keys(), key=lambda s: (-remainders[s], int(s))) + + counts = base_counts.copy() + for i in range(shortfall): + counts[sorted_states[i]] += 1 + + # Only return states that actually have at least 1 count to keep the dict clean + return {k: v for k, v in counts.items() if v > 0} + + +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Convert integer state keys to zero-padded big-endian bitstrings.""" + if num_qubits < 1: + raise ValueError(f"num_qubits must be at least 1, got {num_qubits}.") + + _validate_probabilities(probabilities) + max_state = (1 << num_qubits) - 1 + + result = {} + for state, prob in probabilities.items(): + state_int = int(state) + if state_int < 0 or state_int > max_state: + raise ValueError(f"State integer {state_int} is out of bounds for {num_qubits} qubits.") + + bitstring = f"{state_int:0{num_qubits}b}" + result[bitstring] = prob + + return result + + +def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]: + """ + Compute the marginal distribution over a specified subset of qubits. + Maintains the requested order of the subset qubits in the new state keys. + """ + if not qubits: + raise ValueError("Must specify at least one qubit index to marginalize over.") + if len(set(qubits)) != len(qubits): + raise ValueError("Qubit indices must be unique.") + for q in qubits: + if q < 0 or q >= num_qubits: + raise ValueError(f"Qubit index {q} is out of bounds for {num_qubits} qubits.") + + _validate_probabilities(probabilities) + + result: dict[str, float] = {} + for state, prob in probabilities.items(): + state_int = int(state) + new_state_int = 0 + + # Extract bits big-endian style: qubit 0 is the most significant bit + for i, q in enumerate(qubits): + bit = (state_int >> (num_qubits - 1 - q)) & 1 + new_state_int |= bit << (len(qubits) - 1 - i) + + new_state_str = str(new_state_int) + result[new_state_str] = result.get(new_state_str, 0.0) + prob + + return result + + +def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: + """ + Calculate the Z-parity expectation value: Σ p(x)·(-1)^popcount(x). + """ + _validate_probabilities(probabilities) + max_state = (1 << num_qubits) - 1 + expected_value = 0.0 + + for state, prob in probabilities.items(): + state_int = int(state) + if state_int < 0 or state_int > max_state: + raise ValueError(f"State integer {state_int} is out of bounds for {num_qubits} qubits.") + + # Z-parity is 1 if popcount is even, -1 if popcount is odd + parity = 1 if state_int.bit_count() % 2 == 0 else -1 + expected_value += prob * parity + + return expected_value diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 0000000..cf69151 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,140 @@ +""" +Tests for pure-Python results post-processing helpers. +""" + +import math + +import pytest + +from ionq_core.results import ( + expectation_z, + marginal, + probabilities_to_counts, + relabel_to_bitstrings, +) + +# --- Fixtures --- + + +@pytest.fixture +def bell_state() -> dict[str, float]: + """A standard two-qubit Bell state response.""" + return {"0": 0.5, "3": 0.5} + + +@pytest.fixture +def ghz_state() -> dict[str, float]: + """A three-qubit GHZ state.""" + return {"0": 0.5, "7": 0.5} + + +# --- Validation Tests --- + + +def test_invalid_probabilities(): + """Ensure non-finite and negative probabilities are rejected.""" + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": -0.5}, 100) + + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": math.inf}, 100) + + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": math.nan}, 100) + + +# --- probabilities_to_counts Tests --- + + +def test_probabilities_to_counts_bell(bell_state): + """Test standard perfect distribution.""" + counts = probabilities_to_counts(bell_state, 100) + assert counts == {"0": 50, "3": 50} + + +def test_probabilities_to_counts_rounding(): + """Test largest-remainder method with tricky fractions and deterministic tie-breaking.""" + probs = {"0": 1 / 3, "1": 1 / 3, "2": 1 / 3} + counts = probabilities_to_counts(probs, 10) + # Exact is 3.333 each. Base is 3, 3, 3. Shortfall is 1. + # Tie-breaker should pick the lowest integer state ("0") to get the +1. + assert counts == {"0": 4, "1": 3, "2": 3} + assert sum(counts.values()) == 10 + + +def test_probabilities_to_counts_invalid_shots(bell_state): + with pytest.raises(ValueError, match="at least 1"): + probabilities_to_counts(bell_state, 0) + + +# --- relabel_to_bitstrings Tests --- + + +def test_relabel_to_bitstrings_bell(bell_state): + result = relabel_to_bitstrings(bell_state, 2) + assert result == {"00": 0.5, "11": 0.5} + + +def test_relabel_to_bitstrings_invalid_qubits(bell_state): + with pytest.raises(ValueError, match="at least 1"): + relabel_to_bitstrings(bell_state, 0) + + +def test_relabel_to_bitstrings_out_of_bounds(): + with pytest.raises(ValueError, match="out of bounds"): + relabel_to_bitstrings({"4": 1.0}, 2) + + +# --- marginal Tests --- + + +def test_marginal_bell(bell_state): + """Marginalizing a Bell state on either qubit gives a 50/50 mix.""" + res_q0 = marginal(bell_state, [0], 2) + assert res_q0 == {"0": 0.5, "1": 0.5} + + res_q1 = marginal(bell_state, [1], 2) + assert res_q1 == {"0": 0.5, "1": 0.5} + + +def test_marginal_ghz_subset(ghz_state): + """Extracting qubits 0 and 2 from a 3-qubit GHZ state.""" + # Qubit 0 and 2 from |000> is |00> (state 0). From |111> is |11> (state 3). + res = marginal(ghz_state, [0, 2], 3) + assert res == {"0": 0.5, "3": 0.5} + + +def test_marginal_invalid_inputs(bell_state): + with pytest.raises(ValueError, match="at least one qubit"): + marginal(bell_state, [], 2) + + with pytest.raises(ValueError, match="unique"): + marginal(bell_state, [0, 0], 2) + + with pytest.raises(ValueError, match="out of bounds"): + marginal(bell_state, [2], 2) + + with pytest.raises(ValueError, match="out of bounds"): + marginal(bell_state, [-1], 2) + + +# --- expectation_z Tests --- + + +def test_expectation_z_bell(bell_state): + """ + Z-parity of |00> (popcount 0) is 1. + Z-parity of |11> (popcount 2) is 1. + Total expectation: 0.5*1 + 0.5*1 = 1.0 + """ + assert expectation_z(bell_state, 2) == 1.0 + + +def test_expectation_z_odd_parity(): + """State '1' is '01', popcount 1 -> parity -1.""" + assert expectation_z({"1": 1.0}, 2) == -1.0 + + +def test_expectation_z_out_of_bounds(): + with pytest.raises(ValueError, match="out of bounds"): + expectation_z({"4": 1.0}, 2) From c5a06eadd898422643f20a903680e9490239a7b9 Mon Sep 17 00:00:00 2001 From: scarwizz Date: Tue, 23 Jun 2026 16:04:59 +0530 Subject: [PATCH 2/2] feat: implement pure-Python results helpers with 100% coverage --- AGENTS.md | 1 + CHANGELOG.md | 1 + custom-templates/package_init.py.jinja | 2 +- ionq_core/__init__.py | 5 +- ionq_core/results.py | 260 +++++++++++++++++-------- tests/test_results.py | 218 +++++++++++++-------- 6 files changed, 322 insertions(+), 165 deletions(-) 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..ab03052 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 `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 index 4e6dcd4..fe2c1c8 100644 --- a/ionq_core/results.py +++ b/ionq_core/results.py @@ -1,121 +1,219 @@ -""" -Pure-Python results post-processing helpers for IonQ's probability mappings. +# 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 + ``` """ -import math -from collections.abc import Mapping, Sequence +from __future__ import annotations __all__ = ["expectation_z", "marginal", "probabilities_to_counts", "relabel_to_bitstrings"] - -def _validate_probabilities(probabilities: Mapping[str, float]) -> None: - """Validate that all probabilities are finite and non-negative.""" - for state, prob in probabilities.items(): - if not math.isfinite(prob) or prob < 0.0: - raise ValueError(f"Probability for state '{state}' must be finite and non-negative, got {prob}.") +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} + ``` """ - Convert a probability mapping to exact integer counts summing to `shots`. - - Uses the largest-remainder method (Hare quota) to handle floating-point - rounding errors and guarantee the final counts sum perfectly to `shots`. - """ - if shots < 1: - raise ValueError(f"Shots must be at least 1, got {shots}.") + if shots < 0: + raise ValueError("shots must be non-negative") _validate_probabilities(probabilities) - base_counts = {} - remainders = {} + 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 - for state, prob in probabilities.items(): - exact = prob * shots - base = math.floor(exact) - base_counts[state] = base - remainders[state] = exact - base - shortfall = shots - sum(base_counts.values()) +def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: + """Relabel integer state keys to zero-padded bitstrings. - # Sort by remainder descending. - # Tie-breaker: sort by integer state ascending to make it deterministic. - sorted_states = sorted(remainders.keys(), key=lambda s: (-remainders[s], int(s))) + The bitstring is read left-to-right as qubits ``0, 1, ..., num_qubits - 1`` + (qubit ``0`` most significant), matching IonQ's wire convention. - counts = base_counts.copy() - for i in range(shortfall): - counts[sorted_states[i]] += 1 + Args: + probabilities: State key (decimal-integer encoding) to probability. + num_qubits: Number of qubits; sets the bitstring width. Must be non-negative. - # Only return states that actually have at least 1 count to keep the dict clean - return {k: v for k, v in counts.items() if v > 0} + 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. -def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]: - """Convert integer state keys to zero-padded big-endian bitstrings.""" - if num_qubits < 1: - raise ValueError(f"num_qubits must be at least 1, got {num_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) - max_state = (1 << num_qubits) - 1 + 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 - result = {} - for state, prob in probabilities.items(): - state_int = int(state) - if state_int < 0 or state_int > max_state: - raise ValueError(f"State integer {state_int} is out of bounds for {num_qubits} qubits.") - bitstring = f"{state_int:0{num_qubits}b}" - result[bitstring] = prob +def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]: + """Marginalize a probability mapping over a subset of qubits. - return result + 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}``. -def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]: - """ - Compute the marginal distribution over a specified subset of qubits. - Maintains the requested order of the subset qubits in the new state keys. + 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 not qubits: - raise ValueError("Must specify at least one qubit index to marginalize over.") + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") if len(set(qubits)) != len(qubits): - raise ValueError("Qubit indices must be unique.") - for q in qubits: - if q < 0 or q >= num_qubits: - raise ValueError(f"Qubit index {q} is out of bounds for {num_qubits} 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 state, prob in probabilities.items(): - state_int = int(state) - new_state_int = 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") + 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 - # Extract bits big-endian style: qubit 0 is the most significant bit - for i, q in enumerate(qubits): - bit = (state_int >> (num_qubits - 1 - q)) & 1 - new_state_int |= bit << (len(qubits) - 1 - i) - new_state_str = str(new_state_int) - result[new_state_str] = result.get(new_state_str, 0.0) + prob +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). - return result + 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. -def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float: - """ - Calculate the Z-parity expectation value: Σ p(x)·(-1)^popcount(x). + 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 + ``` """ - _validate_probabilities(probabilities) - max_state = (1 << num_qubits) - 1 - expected_value = 0.0 + if num_qubits < 0: + raise ValueError("num_qubits must be non-negative") - for state, prob in probabilities.items(): - state_int = int(state) - if state_int < 0 or state_int > max_state: - raise ValueError(f"State integer {state_int} is out of bounds for {num_qubits} qubits.") + _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 - # Z-parity is 1 if popcount is even, -1 if popcount is odd - parity = 1 if state_int.bit_count() % 2 == 0 else -1 - expected_value += prob * parity - return expected_value +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 index cf69151..7792f35 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -1,11 +1,15 @@ -""" -Tests for pure-Python results post-processing helpers. -""" +# 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, @@ -13,128 +17,178 @@ relabel_to_bitstrings, ) -# --- Fixtures --- +# 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 ───────────────────────────────────────── -@pytest.fixture -def bell_state() -> dict[str, float]: - """A standard two-qubit Bell state response.""" - return {"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} -@pytest.fixture -def ghz_state() -> dict[str, float]: - """A three-qubit GHZ state.""" - return {"0": 0.5, "7": 0.5} + 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} -# --- Validation Tests --- + 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_invalid_probabilities(): - """Ensure non-finite and negative probabilities are rejected.""" - with pytest.raises(ValueError, match="finite and non-negative"): - probabilities_to_counts({"0": -0.5}, 100) + def test_negative_shots_raises(self): + with pytest.raises(ValueError, match="non-negative"): + probabilities_to_counts(BELL, -1) - with pytest.raises(ValueError, match="finite and non-negative"): - probabilities_to_counts({"0": math.inf}, 100) + def test_non_finite_probability_raises(self): + with pytest.raises(ValueError, match="finite and non-negative"): + probabilities_to_counts({"0": math.nan}, 100) - 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) -# --- probabilities_to_counts Tests --- +# ── relabel_to_bitstrings ─────────────────────────────────────────── -def test_probabilities_to_counts_bell(bell_state): - """Test standard perfect distribution.""" - counts = probabilities_to_counts(bell_state, 100) - assert counts == {"0": 50, "3": 50} +class TestRelabelToBitstrings: + def test_bell_state(self): + assert relabel_to_bitstrings(BELL, 2) == {"00": 0.5, "11": 0.5} -def test_probabilities_to_counts_rounding(): - """Test largest-remainder method with tricky fractions and deterministic tie-breaking.""" - probs = {"0": 1 / 3, "1": 1 / 3, "2": 1 / 3} - counts = probabilities_to_counts(probs, 10) - # Exact is 3.333 each. Base is 3, 3, 3. Shortfall is 1. - # Tie-breaker should pick the lowest integer state ("0") to get the +1. - assert counts == {"0": 4, "1": 3, "2": 3} - assert sum(counts.values()) == 10 + 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_probabilities_to_counts_invalid_shots(bell_state): - with pytest.raises(ValueError, match="at least 1"): - probabilities_to_counts(bell_state, 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) -# --- relabel_to_bitstrings Tests --- + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + relabel_to_bitstrings(BELL, -1) -def test_relabel_to_bitstrings_bell(bell_state): - result = relabel_to_bitstrings(bell_state, 2) - assert result == {"00": 0.5, "11": 0.5} +# ── marginal ──────────────────────────────────────────────────────── -def test_relabel_to_bitstrings_invalid_qubits(bell_state): - with pytest.raises(ValueError, match="at least 1"): - relabel_to_bitstrings(bell_state, 0) +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_relabel_to_bitstrings_out_of_bounds(): - with pytest.raises(ValueError, match="out of bounds"): - relabel_to_bitstrings({"4": 1.0}, 2) + 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} -# --- marginal Tests --- + 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_marginal_bell(bell_state): - """Marginalizing a Bell state on either qubit gives a 50/50 mix.""" - res_q0 = marginal(bell_state, [0], 2) - assert res_q0 == {"0": 0.5, "1": 0.5} + def test_empty_probabilities(self): + assert marginal({}, [0], 1) == {} - res_q1 = marginal(bell_state, [1], 2) - assert res_q1 == {"0": 0.5, "1": 0.5} + 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_marginal_ghz_subset(ghz_state): - """Extracting qubits 0 and 2 from a 3-qubit GHZ state.""" - # Qubit 0 and 2 from |000> is |00> (state 0). From |111> is |11> (state 3). - res = marginal(ghz_state, [0, 2], 3) - assert res == {"0": 0.5, "3": 0.5} + 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_marginal_invalid_inputs(bell_state): - with pytest.raises(ValueError, match="at least one qubit"): - marginal(bell_state, [], 2) + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + marginal(BELL, [0], -1) - with pytest.raises(ValueError, match="unique"): - marginal(bell_state, [0, 0], 2) - with pytest.raises(ValueError, match="out of bounds"): - marginal(bell_state, [2], 2) +# ── expectation_z ─────────────────────────────────────────────────── - with pytest.raises(ValueError, match="out of bounds"): - marginal(bell_state, [-1], 2) +class TestExpectationZ: + def test_bell_state_is_plus_one(self): + assert expectation_z(BELL, 2) == pytest.approx(1.0) -# --- expectation_z Tests --- + 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_expectation_z_bell(bell_state): - """ - Z-parity of |00> (popcount 0) is 1. - Z-parity of |11> (popcount 2) is 1. - Total expectation: 0.5*1 + 0.5*1 = 1.0 - """ - assert expectation_z(bell_state, 2) == 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_expectation_z_odd_parity(): - """State '1' is '01', popcount 1 -> parity -1.""" - assert expectation_z({"1": 1.0}, 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_expectation_z_out_of_bounds(): - with pytest.raises(ValueError, match="out of bounds"): - expectation_z({"4": 1.0}, 2) + def test_negative_num_qubits_raises(self): + with pytest.raises(ValueError, match="non-negative"): + expectation_z(BELL, -1)