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 @@ -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

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.

191 changes: 191 additions & 0 deletions ionq_core/results.py
Original file line number Diff line number Diff line change
@@ -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
140 changes: 140 additions & 0 deletions tests/test_results.py
Original file line number Diff line number Diff line change
@@ -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)