Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- `get_job_artifact` endpoint (`GET /jobs/{UUID}/artifacts/{artifactId}`) for downloading job artifacts by id. The response body is opaque, so only the `sync_detailed` / `asyncio_detailed` callables are generated; read the bytes off `Response.content`.
- `Backend` now exposes `supported_gates`, `supported_native_gates`, and `supported_error_mitigations`.
- `estimate_job_cost` response gained `estimated_quantum_compute_time_us`, and its `rate_information` gained `qct_cost_cents` and `rate_type` (`"qct"` or `"2qge"`). Its `cost_1q_gate`, `cost_2q_gate`, and `job_cost_minimum` rate fields are now nullable.
- `ionq_core.results` module with pure-Python post-processing helpers over the probabilities mapping: `probabilities_to_counts`, `relabel_to_bitstrings`, `marginal`, and `expectation_z`. Keys are little-endian (qubit 0 is the least significant bit).

### Changed

Expand Down
2 changes: 1 addition & 1 deletion custom-templates/package_init.py.jinja
Original file line number Diff line number Diff line change
@@ -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
Expand Down
5 changes: 4 additions & 1 deletion ionq_core/__init__.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

204 changes: 204 additions & 0 deletions ionq_core/results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
# SPDX-FileCopyrightText: 2026 IonQ, Inc.
# SPDX-License-Identifier: Apache-2.0

"""Pure-Python results post-processing helpers.

This module provides helpers over the register-keyed probability mapping
returned by IonQ's results endpoints.

Qubit ordering:
IonQ probability results map integer state keys (as strings) to
probabilities. Throughout this module, qubit i corresponds to
bit 2^i in the integer key -- i.e. qubit 0 is the least significant bit (LSB).

For example, given a 3-qubit circuit the integer key ``4``
(binary ``100``) encodes qubit 0 = 0, qubit 1 = 0, qubit 2 = 1.

Example:
```python
from ionq_core import probabilities_to_counts, relabel_to_bitstrings, marginal, expectation_z

counts = probabilities_to_counts({"0": 0.4, "3": 0.6}, 100) # translate probability results into counts
bitstrings = relabel_to_bitstrings({"0": 0.4, "3": 0.6}, 2) # relabel to bitstrings
marginal_probabilities = marginal({"0": 0.4, "3": 0.6}, [0], 2) # compute the marginal probabilities over qubit 0
expectation = expectation_z({"0": 0.4, "3": 0.6}, 2) # compute the expectation value of the Z-basis observable
```
"""

__all__ = [
"expectation_z",
"marginal",
"probabilities_to_counts",
"relabel_to_bitstrings",
]

import math
from collections import defaultdict
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 counts.

Uses the largest-remainder method so that counts sum exactly to `shots`.

Args:
probabilities: Mapping from integer state keys (as strings) to probabilities.
shots: Total number of counts.

Returns:
Mapping from the same keys to integer counts.

Raises:
ValueError: If ``shots`` is negative.

Examples:
```python
>>> probabilities_to_counts({"0": 0.4, "3": 0.6}, 100) # counts with 100 shots
{'0': 40, '3': 60}
```
"""
if shots < 0:
raise ValueError("Number of shots must be non-negative")
result = {}
remainders = []
remaining_shots = shots
for state, probability in probabilities.items():
result[state] = math.floor(probability * shots)
remainders.append((probability * shots - result[state], state))
remaining_shots -= result[state]

remainders.sort(key=lambda x: (-x[0], int(x[1])))
for _remainder, state in remainders[:remaining_shots]:
result[state] += 1
return result


def relabel_to_bitstrings(probabilities: Mapping[str, float], num_qubits: int) -> dict[str, float]:
"""Convert integer state keys to zero-padded bitstrings.

The most-significant bit is on the left, producing bitstrings in
``q(n-1) ... q1 q0`` order (qubit 0 rightmost).

Args:
probabilities: Mapping from integer state keys (as strings) to probabilities.
num_qubits: Number of qubits used to pad the bitstring.

Returns:
Mapping from bitstrings to probabilities.

Raises:
ValueError: If ``num_qubits`` is not positive, or a state key does not
fit in ``num_qubits`` qubits.

Examples:
```python
>>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 2)
{'00': 0.25, '01': 0.25, '10': 0.25, '11': 0.25}
>>> relabel_to_bitstrings({"0": 0.25, "1":0.25, "2":0.25, "3":0.25}, 3) # example with 3 qubits
{'000': 0.25, '001': 0.25, '010': 0.25, '011': 0.25}
```
"""
if num_qubits <= 0:
raise ValueError("Number of qubits must be positive")
_check_states_in_range(probabilities, num_qubits)
result = {}
for state, probability in probabilities.items():
result[format(int(state), f"0{num_qubits}b")] = probability
return result


def marginal(probabilities: Mapping[str, float], qubits: Sequence[int], num_qubits: int) -> dict[str, float]:
"""Compute the marginal probabilities over a subset of qubits.

Qubit indices follow the module convention: qubit i is bit 2^i
in the integer state key. The output keys are new integers where the
selected qubits are packed in the order given by ``qubits`` --
``qubits[0]`` becomes the most significant bit of the output key.

Args:
probabilities: Mapping from integer state keys (as strings) to probabilities.
qubits: Qubit indices to keep. The order matters: ``qubits[0]`` maps
to the highest bit in the output key.
num_qubits: Total number of qubits in the original state.

Returns:
Mapping from integer state keys (as strings) to marginal probabilities.

Raises:
ValueError: If ``qubits`` is empty, has duplicate or negative indices,
or indexes past ``num_qubits``; if ``num_qubits`` is not positive;
or if a state key does not fit in ``num_qubits`` qubits.

Examples:
```python
>>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [0], 2) # keep qubit 0 (bit 2^0)
{'0': 0.4, '1': 0.6}
>>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [1,0], 2) # keep both, q1 q0 order
{'0': 0.1, '1': 0.2, '2': 0.3, '3': 0.4}
>>> marginal({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, [0,1], 2) # keep both, q0 q1 order (swapped)
{'0': 0.1, '1': 0.3, '2': 0.2, '3': 0.4}
```
"""
if not qubits:
raise ValueError("Qubits sequence cannot be empty")
if min(qubits) < 0:
raise ValueError("Qubit indices must be non-negative")
if num_qubits <= 0:
raise ValueError("Number of qubits must be positive")
_check_states_in_range(probabilities, num_qubits)
if max(qubits) >= num_qubits:
raise ValueError("Qubit indices must be less than the number of qubits")
if len(set(qubits)) != len(qubits):
raise ValueError("Qubit indices must be unique")
result = defaultdict(float)
for state_string, probability in probabilities.items():
state = int(state_string)
marginalized_state = 0
for qubit in qubits:
marginalized_state = (marginalized_state << 1) + ((state >> qubit) & 1)
result[str(marginalized_state)] += probability

return dict(result)


def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float:
"""Calculate the expectation value of the all-qubit parity observable (Z on every qubit).

Each computational basis state contributes +1 when the total number of
qubits in |1> is even, and -1 when odd. This is independent of qubit
ordering.

Args:
probabilities: Mapping from integer state keys (as strings) to probabilities.
num_qubits: Total number of qubits.

Returns:
Expectation value observed in the computational basis (Z basis).

Raises:
ValueError: If ``num_qubits`` is not positive, or a state key does not
fit in ``num_qubits`` qubits.

Examples:
```python
>>> expectation_z({"0": 0.1, "1":0.2, "2":0.3, "3":0.4}, 2) # even-parity states: 0,3; odd-parity: 1,2
0.0
```
"""
if num_qubits <= 0:
raise ValueError("Number of qubits must be positive")
_check_states_in_range(probabilities, num_qubits)
result = 0.0
for state, probability in probabilities.items():
result += (1 - 2 * (int(state).bit_count() & 1)) * probability
return result


def _check_states_in_range(probabilities: Mapping[str, float], num_qubits: int) -> None:
"""Raise if any integer state key does not fit in ``num_qubits`` qubits."""
if not probabilities:
return
max_state = max(int(state) for state in probabilities)
if max_state >= (1 << num_qubits):
raise ValueError(f"State {max_state} is out of range for {num_qubits} qubits")
116 changes: 116 additions & 0 deletions tests/test_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import pytest

from ionq_core.results import (
expectation_z,
marginal,
probabilities_to_counts,
relabel_to_bitstrings,
)

TOLERANCE = 1e-12


def _approx(actual, expected, tol=TOLERANCE):
assert actual.keys() == expected.keys(), f"keys differ: {actual.keys()} != {expected.keys()}"
for key in expected:
assert abs(actual[key] - expected[key]) < tol, f"key {key!r}: {actual[key]} != {expected[key]}"


class TestProbabilitiesToCounts:
@pytest.mark.parametrize(
("probabilities", "shots", "expected"),
[
({"0": 0.4, "3": 0.6}, 100, {"0": 40, "3": 60}),
({"0": 0.496, "3": 0.504}, 100, {"0": 50, "3": 50}),
({"0": 0.251, "1": 0.243, "2": 0.254, "3": 0.252}, 100, {"0": 25, "1": 24, "2": 26, "3": 25}),
({"0": 0.328, "1": 0.332, "2": 0.139, "3": 0.191}, 100, {"0": 33, "1": 34, "2": 14, "3": 19}),
({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 50, {"0": 13, "1": 13, "2": 12, "3": 12}),
({}, 100, {}),
({"0": 0.5, "3": 0.5}, 0, {"0": 0, "3": 0}),
],
)
def test_counts(self, probabilities, shots, expected):
assert probabilities_to_counts(probabilities, shots) == expected

def test_negative_shots(self):
with pytest.raises(ValueError, match="Number of shots must be non-negative"):
probabilities_to_counts({"0": 0.5, "3": 0.5}, -1)


class TestRelabelToBitstrings:
@pytest.mark.parametrize(
("probabilities", "num_qubits", "expected"),
[
({"1": 0.4, "2": 0.6}, 2, {"01": 0.4, "10": 0.6}),
({"1": 0.4, "2": 0.6}, 3, {"001": 0.4, "010": 0.6}),
({}, 3, {}),
],
)
def test_relabel(self, probabilities, num_qubits, expected):
_approx(relabel_to_bitstrings(probabilities, num_qubits), expected)

@pytest.mark.parametrize(
("num_qubits", "match"),
[
(1, "State 2 is out of range for 1 qubits"),
(-1, "Number of qubits must be positive"),
],
)
def test_invalid(self, num_qubits, match):
with pytest.raises(ValueError, match=match):
relabel_to_bitstrings({"1": 0.4, "2": 0.6}, num_qubits)


class TestMarginal:
@pytest.mark.parametrize(
("probabilities", "qubits", "num_qubits", "expected"),
[
({"0": 0.4, "3": 0.6}, [0], 2, {"0": 0.4, "1": 0.6}),
({"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.4}, [0], 2, {"0": 0.4, "1": 0.6}),
({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, [1, 0], 3, {"0": 0.4, "2": 0.2, "3": 0.4}),
({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, [0, 1], 3, {"0": 0.4, "1": 0.2, "3": 0.4}),
({}, [0], 1, {}),
],
)
def test_marginal(self, probabilities, qubits, num_qubits, expected):
_approx(marginal(probabilities, qubits, num_qubits), expected)

@pytest.mark.parametrize(
("qubits", "num_qubits", "match"),
[
([0], -1, "Number of qubits must be positive"),
([0], 1, "State 3 is out of range for 1 qubits"),
([2], 2, "Qubit indices must be less than the number of qubits"),
([-1], 2, "Qubit indices must be non-negative"),
([0, 0], 2, "Qubit indices must be unique"),
([], 2, "Qubits sequence cannot be empty"),
],
)
def test_invalid(self, qubits, num_qubits, match):
with pytest.raises(ValueError, match=match):
marginal({"0": 0.4, "3": 0.6}, qubits, num_qubits)


class TestExpectationZ:
@pytest.mark.parametrize(
("probabilities", "num_qubits", "expected"),
[
({"0": 0.4, "3": 0.6}, 2, 1.0),
({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 2, 0.0),
({"0": 0.1, "2": 0.2, "4": 0.3, "7": 0.4}, 3, -0.8),
({}, 2, 0.0),
],
)
def test_expectation(self, probabilities, num_qubits, expected):
assert abs(expectation_z(probabilities, num_qubits) - expected) < TOLERANCE

@pytest.mark.parametrize(
("num_qubits", "match"),
[
(1, "State 3 is out of range for 1 qubits"),
(-1, "Number of qubits must be positive"),
],
)
def test_invalid(self, num_qubits, match):
with pytest.raises(ValueError, match=match):
expectation_z({"0": 0.4, "3": 0.6}, num_qubits)