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

- `results` module with pure-Python helpers over the probabilities mapping 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). 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"`).

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 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
```
"""

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

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. The returned counts therefore sum to exactly
``shots`` whenever the probabilities are normalized.

Parameters
----------
probabilities : Mapping[str, float]
State key (decimal-integer encoding) to probability.
shots : int
Total number of shots to distribute. Must be non-negative.

Returns
-------
dict[str, int]
State key to integer count, in the iteration order of ``probabilities``.

Raises
------
ValueError
If ``shots`` is negative.
"""
if shots < 0:
raise ValueError("shots must be non-negative")
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])), key))
for key in ranked[:remainder]:
counts[key] += 1
return counts


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

Parameters
----------
probabilities : Mapping[str, float]
State key (decimal-integer encoding) to probability.
num_qubits : int
Number of qubits; sets the bitstring width. Must be non-negative.

Returns
-------
dict[str, float]
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.
"""
if num_qubits < 0:
raise ValueError("num_qubits must be non-negative")
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


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

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).

Parameters
----------
probabilities : Mapping[str, float]
State key (decimal-integer encoding) to probability.
qubits : Sequence[int]
Qubits to keep, each in ``range(num_qubits)``. An empty sequence marginalizes over every
qubit and returns ``{"": total_probability}``.
num_qubits : int
Total number of qubits. Must be non-negative.

Returns
-------
dict[str, float]
Sub-bitstring over ``qubits`` to summed probability.

Raises
------
ValueError
If ``num_qubits`` is negative, a qubit is out of range, or a state key does not fit in
``num_qubits`` qubits.
"""
if num_qubits < 0:
raise ValueError("num_qubits must be non-negative")
selected = list(qubits)
for qubit in selected:
if not 0 <= qubit < num_qubits:
raise ValueError(f"qubit {qubit} out of range for {num_qubits} qubits")
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")
bits = format(value, f"0{num_qubits}b")
sub = "".join(bits[qubit] for qubit in selected)
result[sub] = result.get(sub, 0.0) + probability
return result


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).

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``.

Parameters
----------
probabilities : Mapping[str, float]
State key (decimal-integer encoding) to probability.
num_qubits : int
Number of qubits. Must be non-negative.

Returns
-------
float
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.
"""
if num_qubits < 0:
raise ValueError("num_qubits must be non-negative")
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
109 changes: 109 additions & 0 deletions tests/test_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import pytest

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

# 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}


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}

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_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_negative_shots_raises(self):
with pytest.raises(ValueError, match="non-negative"):
probabilities_to_counts(BELL, -1)


class TestRelabelToBitstrings:
def test_bell_state(self):
assert relabel_to_bitstrings(BELL, 2) == {"00": 0.5, "11": 0.5}

def test_zero_padding_width(self):
assert relabel_to_bitstrings({"5": 1.0}, 4) == {"0101": 1.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)

def test_negative_num_qubits_raises(self):
with pytest.raises(ValueError, match="non-negative"):
relabel_to_bitstrings(BELL, -1)


class TestMarginal:
def test_single_qubit_marginal(self):
assert marginal(BELL, [0], 2) == {"0": 0.5, "1": 0.5}

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_empty_qubits_returns_total(self):
assert marginal(BELL, [], 2) == {"": 1.0}

def test_empty_probabilities(self):
assert marginal({}, [0], 1) == {}

def test_qubit_out_of_range_raises(self):
with pytest.raises(ValueError, match="out of range"):
marginal(BELL, [2], 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_negative_num_qubits_raises(self):
with pytest.raises(ValueError, match="non-negative"):
marginal(BELL, [0], -1)


class TestExpectationZ:
def test_bell_state_is_plus_one(self):
assert expectation_z(BELL, 2) == pytest.approx(1.0)

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_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_negative_num_qubits_raises(self):
with pytest.raises(ValueError, match="non-negative"):
expectation_z(BELL, -1)