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 result post-processing helpers for converting probabilities to counts, bitstring labels, marginals, and all-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.

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

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

IonQ probability endpoints expose mappings whose keys are integer-encoded
measurement states and whose values are probabilities. The helpers here keep
that wire-level shape, but make common SDK tasks easier: deterministic shot
rounding, bitstring labels, marginals, and all-Z parity expectation values.

Bit-ordering convention:
Integer state keys are interpreted as computational-basis integers. Qubit 0
is the least-significant bit, so the two-qubit Bell-state probabilities
``{"0": 0.5, "3": 0.5}`` relabel to ``{"00": 0.5, "11": 0.5}``.
"""

from collections.abc import Iterable, Mapping
from math import floor

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


def probabilities_to_counts(probabilities: Mapping[str, float], shots: int) -> dict[str, int]:
"""Convert probabilities to integer shot counts using largest-remainder rounding.

The returned counts always sum exactly to ``shots``. Ties are broken by the
integer value of the result key, keeping output deterministic for callers and
tests.

Args:
probabilities: IonQ probability mapping, keyed by integer state strings.
shots: Total number of requested shots. Must be non-negative.

Returns:
A mapping with the same keys and integer counts summing to ``shots``.
"""
if shots < 0:
raise ValueError("shots must be non-negative")

floors: dict[str, int] = {}
remainders: list[tuple[float, int, str]] = []
for key, probability in probabilities.items():
expected = probability * shots
count = floor(expected)
floors[key] = count
remainders.append((expected - count, _state_int(key), key))

missing = shots - sum(floors.values())
for _, _, key in sorted(remainders, key=lambda item: (-item[0], item[1], item[2]))[:missing]:
floors[key] += 1

return floors


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

Args:
probabilities: IonQ probability mapping, keyed by integer state strings.
num_qubits: Width of the output bitstrings.

Returns:
Probabilities keyed by zero-padded bitstrings of length ``num_qubits``.
"""
_validate_num_qubits(num_qubits)
bitstring_probabilities: dict[str, float] = {}
for key, probability in probabilities.items():
bitstring = _bitstring(_state_int(key), num_qubits)
bitstring_probabilities[bitstring] = bitstring_probabilities.get(bitstring, 0.0) + probability
return bitstring_probabilities


def marginal(probabilities: Mapping[str, float], qubits: Iterable[int], num_qubits: int) -> dict[str, float]:
"""Marginalize probabilities onto the requested qubits.

Qubit indices follow the integer-key convention: qubit 0 is the
least-significant bit. The output bitstring follows the order in ``qubits``.

Args:
probabilities: IonQ probability mapping, keyed by integer state strings.
qubits: Qubit indices to keep, in output order.
num_qubits: Width of the full result register.

Returns:
Marginal probabilities keyed by selected-qubit bitstrings.
"""
_validate_num_qubits(num_qubits)
selected = tuple(qubits)
for qubit in selected:
if qubit < 0 or qubit >= num_qubits:
raise ValueError("qubits must be within the result register")

result: dict[str, float] = {}
for key, probability in probabilities.items():
state = _state_int(key)
label = "".join(str((state >> qubit) & 1) for qubit in selected)
result[label] = result.get(label, 0.0) + probability
return result


def expectation_z(probabilities: Mapping[str, float], num_qubits: int) -> float:
"""Compute the all-Z parity expectation value for a probability mapping.

The value is ``sum(probability * (-1) ** popcount(state))`` over the
provided result states.

Args:
probabilities: IonQ probability mapping, keyed by integer state strings.
num_qubits: Width of the result register.

Returns:
The all-Z expectation value.
"""
_validate_num_qubits(num_qubits)
return sum(
probability * (-1 if _state_int(key).bit_count() % 2 else 1) for key, probability in probabilities.items()
)


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


def _state_int(key: str) -> int:
state = int(key)
if state < 0:
raise ValueError("state keys must be non-negative integers")
return state


def _bitstring(state: int, num_qubits: int) -> str:
return f"{state:0{num_qubits}b}"
59 changes: 59 additions & 0 deletions tests/test_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# SPDX-FileCopyrightText: 2026 IonQ, Inc.
# SPDX-License-Identifier: Apache-2.0

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_bell_state_rounds_to_exact_shots():
assert probabilities_to_counts(BELL_PROBABILITIES, 100) == {"0": 50, "3": 50}


def test_probabilities_to_counts_uses_largest_remainder_with_stable_ties():
probabilities = {"0": 0.333, "1": 0.333, "2": 0.334}

assert probabilities_to_counts(probabilities, 10) == {"0": 3, "1": 3, "2": 4}
assert probabilities_to_counts({"2": 0.5, "1": 0.5}, 1) == {"2": 0, "1": 1}


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


def test_relabel_to_bitstrings_zero_pads_and_sums_duplicate_integer_labels():
probabilities = {"0": 0.25, "01": 0.25, "3": 0.5}

assert relabel_to_bitstrings(probabilities, 2) == {"00": 0.25, "01": 0.25, "11": 0.5}


def test_relabel_to_bitstrings_rejects_invalid_register_width():
with pytest.raises(ValueError, match="num_qubits"):
relabel_to_bitstrings(BELL_PROBABILITIES, -1)


def test_marginal_uses_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}
assert marginal(BELL_PROBABILITIES, [], 2) == {"": 1.0}


def test_marginal_rejects_out_of_range_qubits():
with pytest.raises(ValueError, match="qubits"):
marginal(BELL_PROBABILITIES, [2], 2)
with pytest.raises(ValueError, match="qubits"):
marginal(BELL_PROBABILITIES, [-1], 2)


def test_expectation_z_computes_all_z_parity():
assert expectation_z(BELL_PROBABILITIES, 2) == 1.0
assert expectation_z({"0": 0.25, "1": 0.25, "2": 0.25, "3": 0.25}, 2) == 0.0


def test_helpers_reject_negative_state_keys():
with pytest.raises(ValueError, match="state keys"):
expectation_z({"-1": 1.0}, 1)