Skip to content
Closed
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand All @@ -39,7 +39,7 @@ jobs:
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand All @@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
timeout-minutes: 15
environment: integration
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: ./.github/actions/setup-uv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Verify tag matches pyproject version
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/spec-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Fetch latest spec
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
token: ${{ secrets.GITHUB_TOKEN }}
advanced-security: false
Expand Down
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 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
Loading