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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Pure-Python results post-processing helpers for converting probabilities to counts, bitstring labels, marginals, and full-register Z expectation values.
- `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"`).

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.

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

"""Pure-Python helpers for post-processing IonQ probability results.

IonQ probability endpoints return mappings from integer-encoded computational
basis states to probabilities. These helpers treat qubit 0 as the least
significant bit, so state ``"1"`` on a two-qubit result is bitstring ``"01"``
when displayed in conventional most-significant-bit-first order.
"""

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

import math
from collections.abc import Iterable, Mapping


def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]:
"""Convert probabilities to integer counts that sum exactly to ``shots``.

Counts are produced with largest-remainder rounding. Ties are broken by the
integer state key, making the result deterministic.

Args:
probabilities: Mapping of integer-encoded state keys to probabilities.
shots: Total number of shots to distribute.

Returns:
A count mapping with the same keys as ``probabilities``.

Raises:
ValueError: If ``shots`` is negative or the probabilities are not
compatible with exact largest-remainder rounding to ``shots``.
"""
if shots < 0:
msg = "shots must be non-negative"
raise ValueError(msg)

quotas = {state: probability * shots for state, probability in probabilities.items()}
counts = {state: math.floor(quota) for state, quota in quotas.items()}
remaining = shots - sum(counts.values())

if remaining < 0 or remaining > len(counts):
msg = "probabilities must sum close enough to 1 to allocate exactly shots counts"
raise ValueError(msg)
if remaining == 0:
return counts

ranked_states = sorted(quotas, key=lambda state: (-(quotas[state] - counts[state]), _state_index(state)))
for state in ranked_states[:remaining]:
counts[state] += 1
return counts


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

Bitstrings are returned most-significant-bit first for readability. Qubit 0
is the least significant bit, so state ``"1"`` becomes ``"01"`` for
``num_qubits=2``.

Args:
probabilities: Mapping of integer-encoded state keys to probabilities.
num_qubits: Number of measured qubits.

Returns:
A probability mapping keyed by zero-padded bitstrings.
"""
_validate_num_qubits(num_qubits)
return {
format(_parse_state_key(state, num_qubits), f"0{num_qubits}b"): probability
for state, probability in probabilities.items()
}


def marginal(probabilities: Mapping[str, float], qubits: Iterable[int], num_qubits: int) -> dict[str, float]:
"""Compute a marginal probability distribution over selected qubits.

The output bitstring follows the order of ``qubits``. For example, with
``qubits=[0, 2]``, the first output bit is qubit 0 and the second output bit
is qubit 2. Qubit 0 is the least significant bit of each integer state key.

Args:
probabilities: Mapping of integer-encoded state keys to probabilities.
qubits: Qubit indices to keep.
num_qubits: Number of measured qubits.

Returns:
A probability mapping over the selected qubits.
"""
selected = _validate_qubits(qubits, num_qubits)
marginals: dict[str, float] = {}
for state_key, probability in probabilities.items():
state = _parse_state_key(state_key, num_qubits)
projected = "".join("1" if state & (1 << qubit) else "0" for qubit in selected)
marginals[projected] = marginals.get(projected, 0.0) + probability
return marginals


def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float:
"""Compute the full-register ``Z tensor ... tensor Z`` expectation value.

This is ``sum(p(x) * (-1) ** popcount(x))`` over all integer-encoded states.
Qubit 0 is the least significant bit.

Args:
probabilities: Mapping of integer-encoded state keys to probabilities.
num_qubits: Number of measured qubits.

Returns:
The parity expectation value.
"""
_validate_num_qubits(num_qubits)
total = 0.0
for state_key, probability in probabilities.items():
state = _parse_state_key(state_key, num_qubits)
sign = -1.0 if state.bit_count() % 2 else 1.0
total += sign * probability
return total


def _state_index(state: str) -> int:
return int(state)


def _parse_state_key(state: str, num_qubits: int) -> int:
parsed = _state_index(state)
if parsed < 0 or parsed >= 1 << num_qubits:
msg = f"state key {state!r} does not fit in {num_qubits} qubits"
raise ValueError(msg)
return parsed


def _validate_num_qubits(num_qubits: int) -> None:
if num_qubits < 0:
msg = "num_qubits must be non-negative"
raise ValueError(msg)


def _validate_qubits(qubits: Iterable[int], num_qubits: int) -> tuple[int, ...]:
_validate_num_qubits(num_qubits)
selected = tuple(qubits)
if len(set(selected)) != len(selected):
msg = "qubits must not contain duplicates"
raise ValueError(msg)
for qubit in selected:
if qubit < 0 or qubit >= num_qubits:
msg = f"qubit {qubit} is outside the range [0, {num_qubits})"
raise ValueError(msg)
return selected
108 changes: 108 additions & 0 deletions tests/test_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import pytest

from ionq_core import expectation_z, marginal, probabilities_to_counts, relabel_to_bitstrings

BELL_PROBABILITIES = {"0": 0.5, "3": 0.5}


def test_probabilities_to_counts_uses_largest_remainder_rounding():
assert probabilities_to_counts({"0": 0.333, "1": 0.333, "2": 0.334}, 10) == {
"0": 3,
"1": 3,
"2": 4,
}


def test_probabilities_to_counts_breaks_ties_by_integer_state():
assert probabilities_to_counts({"3": 0.25, "2": 0.25, "1": 0.25, "0": 0.25}, 2) == {
"3": 0,
"2": 0,
"1": 1,
"0": 1,
}


def test_probabilities_to_counts_returns_exact_integer_counts_for_bell_state():
counts = probabilities_to_counts(BELL_PROBABILITIES, 101)

assert counts == {"0": 51, "3": 50}
assert sum(counts.values()) == 101


def test_probabilities_to_counts_returns_floor_counts_without_remainder():
assert probabilities_to_counts(BELL_PROBABILITIES, 100) == {"0": 50, "3": 50}


def test_probabilities_to_counts_rejects_negative_shots():
with pytest.raises(ValueError, match="shots must be non-negative"):
probabilities_to_counts(BELL_PROBABILITIES, -1)


def test_probabilities_to_counts_rejects_probabilities_too_large():
with pytest.raises(ValueError, match="probabilities must sum"):
probabilities_to_counts({"0": 0.75, "1": 0.75}, 10)


def test_probabilities_to_counts_rejects_probabilities_too_small():
with pytest.raises(ValueError, match="probabilities must sum"):
probabilities_to_counts({"0": 0.1, "1": 0.1}, 10)


def test_relabel_to_bitstrings_zero_pads_integer_state_keys():
assert relabel_to_bitstrings({"0": 0.5, "1": 0.25, "5": 0.25}, 3) == {
"000": 0.5,
"001": 0.25,
"101": 0.25,
}


def test_relabel_to_bitstrings_rejects_negative_num_qubits():
with pytest.raises(ValueError, match="num_qubits must be non-negative"):
relabel_to_bitstrings(BELL_PROBABILITIES, -1)


def test_relabel_to_bitstrings_rejects_state_outside_register():
with pytest.raises(ValueError, match="does not fit"):
relabel_to_bitstrings({"4": 1.0}, 2)


def test_marginal_keeps_requested_qubit_order():
probabilities = {"0": 0.1, "1": 0.2, "4": 0.3, "5": 0.4}

assert marginal(probabilities, [0, 2], 3) == {
"00": 0.1,
"10": 0.2,
"01": 0.3,
"11": 0.4,
}


def test_marginal_combines_probability_mass():
assert marginal(BELL_PROBABILITIES, [0], 2) == {"0": 0.5, "1": 0.5}


def test_marginal_over_no_qubits_returns_total_probability():
assert marginal(BELL_PROBABILITIES, [], 2) == {"": 1.0}


def test_marginal_rejects_duplicate_qubits():
with pytest.raises(ValueError, match="duplicates"):
marginal(BELL_PROBABILITIES, [0, 0], 2)


def test_marginal_rejects_qubit_outside_register():
with pytest.raises(ValueError, match="outside the range"):
marginal(BELL_PROBABILITIES, [2], 2)


def test_expectation_z_for_bell_state_is_one():
assert expectation_z(BELL_PROBABILITIES, 2) == 1.0


def test_expectation_z_uses_parity_sign():
assert expectation_z({"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.4}, 2) == pytest.approx(0.0)


def test_expectation_z_rejects_state_outside_register():
with pytest.raises(ValueError, match="does not fit"):
expectation_z({"8": 1.0}, 3)