diff --git a/CHANGELOG.md b/CHANGELOG.md index c655fdb..cf99380 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 +- A Hamiltonian Energy quantum-function optimization example using the free simulator backend. - `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..b37b88d 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,9 @@ 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/). +Runnable examples, including a Hamiltonian Energy quantum-function optimization +loop, live in [`examples/`](examples/). + ## 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..8fd9e8a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples + +These examples use `ionq-core` directly against the IonQ Cloud Platform API. + +## Setup + +```sh +python -m pip install ionq-core +export IONQ_API_KEY=... +``` + +The client reads `IONQ_API_KEY` from the environment and sends it with IonQ's +`apiKey` authorization scheme. + +## Hamiltonian energy quantum function + +Run a small client-side optimization loop that submits Hamiltonian Energy +quantum-function jobs to the free `simulator` backend: + +```sh +python examples/hamiltonian_energy_optimization.py +``` + +The example builds the quantum-function payload with the generated typed +models, submits each parameter vector with `create_job`, waits with +`wait_for_job`, and prints the per-iteration energy plus the final parameters. diff --git a/examples/hamiltonian_energy_optimization.py b/examples/hamiltonian_energy_optimization.py new file mode 100644 index 0000000..61ca2ea --- /dev/null +++ b/examples/hamiltonian_energy_optimization.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Optimize a two-qubit Hamiltonian Energy quantum function on the simulator. + +Hamiltonian: + H = -1.0 * ZI - 1.0 * IZ + 0.5 * ZZ + +Ansatz: + A compact two-qubit QIS-style ansatz serialized into `Ansatz.data`, with + two rotation parameters followed by a CNOT entangler. + +Optimizer: + A dependency-free coordinate search. Each iteration evaluates the current + parameter vector and one positive/negative step along every coordinate, + then halves the step size before the next iteration. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable, Mapping +from typing import cast + +from ionq_core import AuthenticatedClient, IonQClient, wait_for_job +from ionq_core.api.default import create_job +from ionq_core.models import ( + Ansatz, + HamiltonianEnergyData, + HamiltonianEnergyInput, + HamiltonianEnergyInputData, + HamiltonianPauliTerm, + JobCreationResponse, + QuantumFunctionJobCreationPayload, +) + +INITIAL_PARAMS = [0.1, 0.4] +ITERATIONS = 4 +SHOTS = 100 + +# `Ansatz.data` is intentionally a string in the public API. This payload keeps +# the example self-contained and mirrors IonQ's JSON circuit shape. +ANSATZ_DATA = json.dumps( + { + "qubits": 2, + "gateset": "qis", + "circuit": [ + {"gate": "ry", "target": 0, "rotation": "theta_0"}, + {"gate": "ry", "target": 1, "rotation": "theta_1"}, + {"gate": "cnot", "control": 0, "target": 1}, + ], + "parameters": ["theta_0", "theta_1"], + }, + separators=(",", ":"), +) + + +def build_hamiltonian_energy_payload(params: list[float]) -> QuantumFunctionJobCreationPayload: + """Build a typed Hamiltonian Energy job payload for one parameter vector.""" + return QuantumFunctionJobCreationPayload( + backend="simulator", + type_="quantum-function", + shots=SHOTS, + name="Hamiltonian energy coordinate search", + input_=HamiltonianEnergyInput( + data=HamiltonianEnergyInputData( + type_="hamiltonian-energy", + data=HamiltonianEnergyData( + hamiltonian=[ + HamiltonianPauliTerm(pauli_string="ZI", coefficient=-1.0), + HamiltonianPauliTerm(pauli_string="IZ", coefficient=-1.0), + HamiltonianPauliTerm(pauli_string="ZZ", coefficient=0.5), + ], + ansatz=Ansatz(data=ANSATZ_DATA), + ), + ), + params=params, + ), + ) + + +def evaluate_energy(client: AuthenticatedClient, params: list[float]) -> float: + """Submit one Hamiltonian Energy job and return its completed energy.""" + submitted = create_job.sync(client=client, body=build_hamiltonian_energy_payload(params)) + if not isinstance(submitted, JobCreationResponse): + msg = f"Expected JobCreationResponse, received {type(submitted).__name__}" + raise RuntimeError(msg) + + completed = wait_for_job(client, submitted.id) + return extract_energy(completed.output.to_dict()) + + +def extract_energy(output: Mapping[str, object]) -> float: + """Extract an energy value from common quantum-function response shapes.""" + direct = _first_numeric(output, ("energy", "value", "expectation_value", "minimum_value")) + if direct is not None: + return direct + + for nested_key in ("result", "results", "solution", "output"): + nested = output.get(nested_key) + if isinstance(nested, Mapping): + if not all(isinstance(key, str) for key in nested): + continue + nested_value = _first_numeric( + cast(Mapping[str, object], nested), + ("energy", "value", "expectation_value", "minimum_value"), + ) + if nested_value is not None: + return nested_value + + msg = f"Could not find an energy value in job output keys: {sorted(output)}" + raise RuntimeError(msg) + + +def coordinate_search( + evaluate: Callable[[list[float]], float], + initial_params: list[float], + *, + iterations: int = ITERATIONS, + step: float = 0.4, +) -> tuple[list[float], float]: + """Minimize an energy callback with a small coordinate-search loop.""" + params = list(initial_params) + best_energy = evaluate(params) + + for iteration in range(1, iterations + 1): + for index in range(len(params)): + for direction in (1.0, -1.0): + candidate = list(params) + candidate[index] += direction * step + candidate_energy = evaluate(candidate) + if candidate_energy < best_energy: + params = candidate + best_energy = candidate_energy + print(f"iteration={iteration} energy={best_energy:.8f} params={params}") + step *= 0.5 + + return params, best_energy + + +def _first_numeric(values: Mapping[str, object], keys: tuple[str, ...]) -> float | None: + for key in keys: + value = values.get(key) + if isinstance(value, int | float): + return float(value) + return None + + +def main() -> None: + """Run the optimization loop.""" + client = IonQClient() + best_params, best_energy = coordinate_search(lambda params: evaluate_energy(client, params), INITIAL_PARAMS) + print(f"final_energy={best_energy:.8f}") + print(f"final_params={best_params}") + + +if __name__ == "__main__": + main()