diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..baadf25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- `examples/` with a Hamiltonian-energy Quantum Function workload and client-side parameter optimization. - `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"`). diff --git a/README.md b/README.md index 992bbde..5f63819 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ Each generated endpoint module exposes four callables: `sync`, `sync_detailed`, For options (`api_key`, `base_url`, `max_retries`, `timeout`, `extension`), error classes, retry behavior, pagination, polling, sessions, and downstream-SDK extension hooks, see the [API reference](https://ionq.github.io/ionq-core-python/). +## Examples + +See [examples/README.md](examples/README.md) for runnable examples, including a Hamiltonian-energy Quantum Function workload with client-side parameter optimization. + ## Versioning This package follows [SemVer 2.0](https://semver.org/spec/v2.0.0.html), independent of the upstream REST API version - pass an explicit `base_url` to `IonQClient` to pin against a different API. Print the installed version with: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..94f0ddf --- /dev/null +++ b/examples/README.md @@ -0,0 +1,20 @@ +# ionq-core Examples + +## Hamiltonian Energy Optimization + +`hamiltonian_energy_optimization.py` demonstrates a Hamiltonian-energy Quantum Function using only the public `ionq-core` API: + +- `IonQClient` +- `create_job.sync` +- `wait_for_job` +- typed models under `ionq_core.models` + +It minimizes the one-qubit Hamiltonian `H = -Z` with a parameterized OpenQASM 3 `RY(theta)` ansatz and a small dependency-free coordinate search optimizer. + +```sh +pip install ionq-core +export IONQ_API_KEY=... +python examples/hamiltonian_energy_optimization.py --iterations 4 +``` + +The script targets the free `simulator` backend by default and prints each optimization iteration plus the final energy and parameters. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..8a56161 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Runnable ionq-core examples.""" diff --git a/examples/hamiltonian_energy_optimization.py b/examples/hamiltonian_energy_optimization.py new file mode 100644 index 0000000..0fec5ff --- /dev/null +++ b/examples/hamiltonian_energy_optimization.py @@ -0,0 +1,189 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Hamiltonian-energy Quantum Function example with client-side optimization. + +This example minimizes the one-qubit Hamiltonian ``H = -Z`` with a +parameterized OpenQASM 3 ansatz, ``RY(theta) |0>``. The optimizer is a small +dependency-free coordinate search that repeatedly submits Hamiltonian-energy +Quantum Function jobs through `ionq-core`. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any + +from ionq_core import AuthenticatedClient, IonQClient, wait_for_job +from ionq_core.api.default import create_job +from ionq_core.models.ansatz import Ansatz +from ionq_core.models.hamiltonian_energy_data import HamiltonianEnergyData +from ionq_core.models.hamiltonian_energy_input import HamiltonianEnergyInput +from ionq_core.models.hamiltonian_energy_input_data import HamiltonianEnergyInputData +from ionq_core.models.hamiltonian_pauli_term import HamiltonianPauliTerm +from ionq_core.models.quantum_function_job_creation_payload import QuantumFunctionJobCreationPayload + +ANSATZ_OPENQASM = """OPENQASM 3.0; +input float theta; +qubit[1] q; +ry(theta) q[0]; +""" + + +@dataclass(frozen=True) +class OptimizationResult: + """Result from the client-side coordinate-search loop.""" + + parameters: list[float] + energy: float + history: list[tuple[int, list[float], float]] + + +def build_hamiltonian_energy_payload( + params: Sequence[float], + *, + backend: str = "simulator", + shots: int = 100, + name: str = "ionq-core hamiltonian energy example", +) -> QuantumFunctionJobCreationPayload: + """Build a typed Hamiltonian-energy Quantum Function payload.""" + return QuantumFunctionJobCreationPayload( + backend=backend, + type_="quantum-function", + input_=HamiltonianEnergyInput( + data=HamiltonianEnergyInputData( + type_="hamiltonian-energy", + data=HamiltonianEnergyData( + hamiltonian=[HamiltonianPauliTerm(pauli_string="Z", coefficient=-1.0)], + ansatz=Ansatz(data=ANSATZ_OPENQASM), + ), + ), + params=list(params), + ), + name=name, + shots=shots, + ) + + +def evaluate_energy( + client: AuthenticatedClient, + params: Sequence[float], + *, + backend: str = "simulator", + shots: int = 100, + poll_interval: float = 1.0, + timeout: float = 300.0, + create_job_sync: Callable[..., Any] = create_job.sync, + wait_for_job_fn: Callable[..., Any] = wait_for_job, +) -> float: + """Submit a Hamiltonian-energy job and return its completed energy.""" + payload = build_hamiltonian_energy_payload(params, backend=backend, shots=shots) + created = create_job_sync(client=client, body=payload) + if created is None: + raise RuntimeError("IonQ API did not return a job creation response") + + completed = wait_for_job_fn(client, created.id, poll_interval=poll_interval, timeout=timeout) + return extract_energy(completed) + + +def extract_energy(completed_job: Any) -> float: + """Extract an energy value from a completed Quantum Function job response.""" + output = completed_job.output.to_dict() + candidates = [output.get("energy"), output.get("value")] + + result = output.get("result") + if isinstance(result, dict): + candidates.append(result.get("energy")) + + solution = output.get("solution") + if isinstance(solution, dict): + candidates.append(solution.get("minimum_value")) + + for candidate in candidates: + if isinstance(candidate, (int, float)): + return float(candidate) + + raise KeyError("completed job output did not contain an energy value") + + +def coordinate_search( + energy_fn: Callable[[list[float]], float], + initial_params: Sequence[float], + *, + step_size: float = 0.4, + min_step: float = 0.025, + max_iterations: int = 8, + log: Callable[[str], None] = print, +) -> OptimizationResult: + """Minimize ``energy_fn`` with a small deterministic coordinate search.""" + params = list(initial_params) + energy = energy_fn(params) + history = [(0, params.copy(), energy)] + log(f"iteration 0: energy={energy:.12g}, params={params}") + + step = step_size + for iteration in range(1, max_iterations + 1): + improved = False + + for index in range(len(params)): + for direction in (1.0, -1.0): + candidate = params.copy() + candidate[index] += direction * step + candidate_energy = energy_fn(candidate) + if candidate_energy < energy: + params = candidate + energy = candidate_energy + improved = True + + if improved: + history.append((iteration, params.copy(), energy)) + else: + step *= 0.5 + + log(f"iteration {iteration}: energy={energy:.12g}, params={params}, step={step:.12g}") + + if not improved and step < min_step: + break + + return OptimizationResult(parameters=params, energy=energy, history=history) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the Hamiltonian-energy example from the command line.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", default="simulator", help="IonQ backend to target") + parser.add_argument("--shots", type=int, default=100, help="Shots per energy evaluation") + parser.add_argument("--initial-theta", type=float, default=1.2, help="Initial RY angle") + parser.add_argument("--iterations", type=int, default=4, help="Maximum coordinate-search iterations") + parser.add_argument("--step", type=float, default=0.4, help="Initial coordinate-search step") + parser.add_argument("--poll-interval", type=float, default=1.0, help="Initial polling interval in seconds") + parser.add_argument("--timeout", type=float, default=300.0, help="Polling timeout per submitted job") + args = parser.parse_args(argv) + + client = IonQClient() + + def energy_fn(params: list[float]) -> float: + return evaluate_energy( + client, + params, + backend=args.backend, + shots=args.shots, + poll_interval=args.poll_interval, + timeout=args.timeout, + ) + + result = coordinate_search( + energy_fn, + [args.initial_theta], + step_size=args.step, + max_iterations=args.iterations, + ) + print(f"final_energy={result.energy:.12g}") + print(f"optimal_parameters={result.parameters}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..adbe97f --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from examples import hamiltonian_energy_optimization as example + + +def test_hamiltonian_energy_payload_uses_typed_models(): + payload = example.build_hamiltonian_energy_payload([0.25], shots=200) + + assert payload.to_dict() == { + "backend": "simulator", + "type": "quantum-function", + "input": { + "data": { + "type": "hamiltonian-energy", + "data": { + "hamiltonian": [{"pauli_string": "Z", "coefficient": -1.0}], + "ansatz": {"data": example.ANSATZ_OPENQASM}, + "penalty": 0.0, + }, + }, + "params": [0.25], + }, + "name": "ionq-core hamiltonian energy example", + "shots": 200, + } + + +def test_evaluate_energy_submits_payload_and_polls_job(auth_client): + calls = [] + + class CreatedJob: + id = "job-123" + + class CompletedOutput: + def to_dict(self): + return {"energy": -0.75} + + class CompletedJob: + output = CompletedOutput() + + def fake_create_job(*, client, body): + calls.append(("create", client, body.to_dict())) + return CreatedJob() + + def fake_wait_for_job(client, job_id, *, poll_interval, timeout): + calls.append(("wait", client, job_id, poll_interval, timeout)) + return CompletedJob() + + energy = example.evaluate_energy( + auth_client, + [0.5], + create_job_sync=fake_create_job, + wait_for_job_fn=fake_wait_for_job, + poll_interval=0.25, + timeout=12.0, + ) + + assert energy == -0.75 + assert calls[0][0] == "create" + assert calls[0][1] is auth_client + assert calls[0][2]["input"]["params"] == [0.5] + assert calls[1] == ("wait", auth_client, "job-123", 0.25, 12.0) + + +def test_coordinate_search_reports_and_lowers_energy(): + messages = [] + + def bowl(params): + return (params[0] - 0.25) ** 2 + + result = example.coordinate_search( + bowl, + [1.25], + step_size=0.5, + min_step=0.125, + max_iterations=4, + log=messages.append, + ) + + assert result.energy == pytest.approx(0.0) + assert result.parameters == pytest.approx([0.25]) + assert result.history[-1] == (2, [0.25], 0.0) + assert any(message.startswith("iteration 2:") for message in messages)