diff --git a/README.md b/README.md index 992bbde..3cc1f3a 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 + +End-to-end scripts live under [`examples/`](examples/). See [`examples/README.md`](examples/README.md) for setup (`pip install ionq-core`, `export IONQ_API_KEY=...`) and the Hamiltonian-energy VQE walkthrough. + ## 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..a4cc1ca --- /dev/null +++ b/examples/README.md @@ -0,0 +1,72 @@ +# Examples + +Runnable scripts that exercise `ionq-core` against the live IonQ API. + +## Hamiltonian-energy VQE + +[`hamiltonian_energy_vqe.py`](hamiltonian_energy_vqe.py) builds a Hamiltonian Energy +Quantum Function with typed `ionq_core.models`, submits each evaluation via +`create_job`, polls with `wait_for_job`, and minimizes the energy with a +dependency-free SPSA optimizer. + +| Component | Choice | +| --------- | ------ | +| Hamiltonian | H2 (STO-3G, r = 0.735 Å): `IZ`, `ZI`, `ZZ` (see note below) | +| Ansatz | Ry/Rz + CNOT + Ry/Rz (8 parameters, OpenQASM 3) | +| Optimizer | SPSA — two energy evaluations per iteration; keeps best seen | +| Backend | `simulator` (free tier) | + +### Energy expectations + +The script optimizes the **submitted** Pauli terms (`IZ`, `ZI`, `ZZ`). Two +details matter when comparing numbers: + +1. **`II` term omitted** - the full H2 operator includes a constant `-0.0113` Ha + identity shift. The API ignores `"II"` Pauli strings, so the script leaves it + out and reports a shifted energy at the end. +2. **Shallow demo ansatz** - this is a small hardware-efficient circuit, not a + full UCCSD ansatz. Expect the best submitted energy around **-0.15 to -0.25 + Ha** after SPSA, not the exact ground state. + +| Quantity | Typical value | +| -------- | ------------- | +| Best energy (submitted terms) | ~ -0.15 to -0.25 Ha | +| + omitted `II` offset | add -0.0113 Ha | +| Exact ground state (reference) | ~ -1.137 Ha | + +The exact reference is what a complete variational ansatz would target; this +example demonstrates the API loop, not state-of-the-art H2 convergence. + +### Setup + +```sh +pip install ionq-core +``` + +Get an API key from [cloud.ionq.com/settings/keys](https://cloud.ionq.com/settings/keys). + +### Running + +```sh +# Unix / macOS +export IONQ_API_KEY=your-api-key +python examples/hamiltonian_energy_vqe.py +``` + +```powershell +# Windows PowerShell +$env:IONQ_API_KEY = "your-api-key" +python examples/hamiltonian_energy_vqe.py +``` + +From a clone of this repository (with dev tools installed): + +```sh +uv run python examples/hamiltonian_energy_vqe.py +``` + +The script prints each job submission, per-iteration SPSA progress (`E+` / `E−`), +the best energy on the submitted Hamiltonian, a shifted total that adds the +omitted `II` offset back, and the exact reference. Swap SPSA for `scipy.optimize` or +another method if you prefer — keep extra dependencies example-only +(`pip install scipy`), not in the core package. diff --git a/examples/hamiltonian_energy_vqe.py b/examples/hamiltonian_energy_vqe.py new file mode 100644 index 0000000..60907a3 --- /dev/null +++ b/examples/hamiltonian_energy_vqe.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: 2026 IonQ, Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Variational Hamiltonian-energy minimization on IonQ's free simulator. + +Hamiltonian + H2 molecule at equilibrium bond length (STO-3G, Jordan-Wigner, 2 qubits): + ``0.1714·IZ + 0.1714·ZI + 0.1687·ZZ`` (Hartree). The ``II`` identity term + (``-0.0113`` Ha) is omitted - it is a constant energy shift and the API + ignores ``"II"`` Pauli strings. The exact ground state including that + offset is ~ ``-1.137`` Ha. + +Ansatz + Hardware-efficient OpenQASM 3 circuit: Ry/Rz on each qubit, CNOT, Ry/Rz + again (eight parameters), matching the Hosted Hybrid guide pattern. + +Optimizer + Hand-rolled SPSA (no scipy). Two energy evaluations per iteration; tracks + the best energy seen across every evaluation. + +Each energy evaluation builds typed ``ionq_core.models`` payloads, calls +``create_job``, ``wait_for_job``, then reads ``results["value"]`` from the +raw job JSON (the typed ``CircuitJobResult`` model omits it). +""" + +from __future__ import annotations + +import json +import logging +import math +import random +import sys +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from ionq_core import IonQClient, wait_for_job +from ionq_core.api.default import create_job, get_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 + +if TYPE_CHECKING: + from ionq_core.client import AuthenticatedClient + +logger = logging.getLogger(__name__) + +# H2 (STO-3G, r = 0.735 Å). Submit only non-identity Pauli terms; II is a constant offset. +H2_HAMILTONIAN: tuple[tuple[str, float], ...] = ( + ("IZ", 0.171413198), + ("ZI", 0.171413198), + ("ZZ", 0.168688319), +) +# Exact ground state of the full operator (including the omitted II term). +H2_EXACT_GROUND_HA = -1.1372813 +H2_IDENTITY_OFFSET_HA = -0.011280524 + +# Ry/Rz layer, CNOT, Ry/Rz layer — scalar inputs (Qiskit qasm3 style). +H2_ANSAZ_QASM = """\ +OPENQASM 3.0; +include "stdgates.inc"; +input float[64] theta0; +input float[64] theta1; +input float[64] theta2; +input float[64] theta3; +input float[64] theta4; +input float[64] theta5; +input float[64] theta6; +input float[64] theta7; +qubit[2] q; +ry(theta0) q[0]; +rz(theta1) q[0]; +ry(theta2) q[1]; +rz(theta3) q[1]; +cx q[0], q[1]; +ry(theta4) q[0]; +rz(theta5) q[0]; +ry(theta6) q[1]; +rz(theta7) q[1]; +""" + +NUM_PARAMS = 8 +SHOTS = 1000 +JOB_TIMEOUT_S = 300.0 +SPSA_MAXITER = 20 +SPSA_LEARNING_RATE = 0.1 +SPSA_PERTURBATION = 0.3 + + +@dataclass(frozen=True) +class Problem: + """Fixed Hamiltonian and ansatz; params vary per evaluation.""" + + hamiltonian: tuple[HamiltonianPauliTerm, ...] + ansatz: Ansatz + + +def build_problem() -> Problem: + terms = tuple(HamiltonianPauliTerm(pauli_string=s, coefficient=c) for s, c in H2_HAMILTONIAN) + return Problem(hamiltonian=terms, ansatz=Ansatz(data=H2_ANSAZ_QASM)) + + +def initial_params(*, seed: int = 42) -> list[float]: + rng = random.Random(seed) + return [rng.uniform(0.0, 2.0 * math.pi) for _ in range(NUM_PARAMS)] + + +def _energy_from_block(block: Mapping[str, Any]) -> float | None: + value = block.get("value") + if value is None: + return None + return float(value) + + +def _energy_from_job(client: AuthenticatedClient, job_id: str) -> float: + # Quantum-function jobs put energy in results.value; the typed CircuitJobResult model drops it. + resp = get_job.sync_detailed(uuid=job_id, client=client) + if resp.status_code.value != 200: + raise RuntimeError(f"get_job {job_id} returned HTTP {resp.status_code.value}") + body = json.loads(resp.content) + for section in ("results", "output"): + block = body.get(section) + if isinstance(block, dict): + energy = _energy_from_block(block) + if energy is not None: + return energy + results = body.get("results") + output = body.get("output") + raise ValueError(f"completed job {job_id} missing results/output value; results={results!r}, output={output!r}") + + +def evaluate_energy( + client: AuthenticatedClient, + problem: Problem, + params: Sequence[float], +) -> tuple[str, float]: + """Submit one hamiltonian-energy job and return ``(job_id, energy)``.""" + payload = QuantumFunctionJobCreationPayload( + backend="simulator", + type_="quantum-function", + name="hamiltonian-energy-vqe", + shots=SHOTS, + input_=HamiltonianEnergyInput( + data=HamiltonianEnergyInputData( + type_="hamiltonian-energy", + data=HamiltonianEnergyData( + hamiltonian=list(problem.hamiltonian), + ansatz=problem.ansatz, + ), + ), + params=list(params), + ), + ) + created = create_job.sync(client=client, body=payload) + if created is None: + raise RuntimeError("create_job returned None") + wait_for_job(client, created.id, timeout=JOB_TIMEOUT_S) + energy = _energy_from_job(client, created.id) + logger.info("job=%s params=%s energy=%.6f", created.id, [round(p, 4) for p in params], energy) + return created.id, energy + + +def spsa_minimize( + objective: Callable[[list[float]], float], + x0: list[float], + *, + maxiter: int = SPSA_MAXITER, + learning_rate: float = SPSA_LEARNING_RATE, + perturbation: float = SPSA_PERTURBATION, + lr_decay: float = 0.602, + pert_decay: float = 0.101, + stability: float = 1.0, + seed: int = 0, +) -> tuple[list[float], float]: + """Simultaneous perturbation stochastic approximation (gradient-free). + + Uses two objective evaluations per iteration (θ±δ). The returned optimum + is the lowest energy seen across every evaluation, not just the final θ. + """ + rng = random.Random(seed) + params = list(x0) + best_params = params[:] + best_val = objective(params) + print(f"iter 0/{maxiter} energy = {best_val:+.6f} best = {best_val:+.6f}") + + for k in range(1, maxiter + 1): + ak = learning_rate / (k + stability) ** lr_decay + ck = perturbation / k**pert_decay + delta = [rng.choice([-1.0, 1.0]) for _ in params] + x_plus = [xi + ck * di for xi, di in zip(params, delta, strict=True)] + x_minus = [xi - ck * di for xi, di in zip(params, delta, strict=True)] + y_plus = objective(x_plus) + y_minus = objective(x_minus) + if y_plus < best_val: + best_val = y_plus + best_params = x_plus[:] + if y_minus < best_val: + best_val = y_minus + best_params = x_minus[:] + gradient = [(y_plus - y_minus) / (2.0 * ck) * di for di in delta] + params = [xi - ak * gi for xi, gi in zip(params, gradient, strict=True)] + print(f"iter {k:>3}/{maxiter} E+ = {y_plus:+.6f} E- = {y_minus:+.6f} best = {best_val:+.6f}") + + return best_params, best_val + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + client = IonQClient(additional_user_agent="ionq-core-example/hamiltonian-energy-vqe") + problem = build_problem() + params0 = initial_params() + + print("H2 VQE on simulator (hamiltonian-energy quantum function)") + print(f" Hamiltonian: {len(problem.hamiltonian)} Pauli terms (STO-3G, r = 0.735 Å)") + print(f" Exact ground energy (incl. omitted II term): {H2_EXACT_GROUND_HA:+.4f} Ha") + print(f" Ansatz: Ry/Rz + CNOT + Ry/Rz ({NUM_PARAMS} parameters)") + print(f" Optimizer: SPSA, maxiter={SPSA_MAXITER}, shots={SHOTS}") + print(" Expect best energy roughly -0.15 to -0.25 Ha with this shallow demo ansatz.") + print(f" Initial params: {[round(p, 4) for p in params0]}") + print() + + eval_count = 0 + + def energy_fn(p: list[float]) -> float: + nonlocal eval_count + eval_count += 1 + job_id, energy = evaluate_energy(client, problem, p) + print(f" [eval {eval_count:>2}] job={job_id} energy={energy:+.6f} params={[round(x, 4) for x in p]}") + return energy + + optimal_params, optimal_energy = spsa_minimize(energy_fn, params0) + shifted_energy = optimal_energy + H2_IDENTITY_OFFSET_HA + print() + print(f"Optimization complete ({eval_count} energy evaluations)") + print(f"Best energy (submitted Hamiltonian): {optimal_energy:+.8f} Ha") + print(f"Shifted by II offset ({H2_IDENTITY_OFFSET_HA:+.6f} Ha): {shifted_energy:+.8f} Ha") + print(f"Exact ground state reference: {H2_EXACT_GROUND_HA:+.8f} Ha") + print(f"Best params: {[round(p, 6) for p in optimal_params]}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ionq_core/models/cost_model.py b/ionq_core/models/cost_model.py index c464277..1d325b2 100644 --- a/ionq_core/models/cost_model.py +++ b/ionq_core/models/cost_model.py @@ -4,9 +4,9 @@ from typing import Literal, cast -CostModel = Literal['execution_time', 'quantum_compute_time'] +CostModel = Literal['2QGE_operations', 'execution_time', 'quantum_compute_time'] -COST_MODEL_VALUES: set[CostModel] = { 'execution_time', 'quantum_compute_time', } +COST_MODEL_VALUES: set[CostModel] = { '2QGE_operations', 'execution_time', 'quantum_compute_time', } def check_cost_model(value: str) -> CostModel: if value in COST_MODEL_VALUES: diff --git a/openapi-overlay.yaml b/openapi-overlay.yaml index d371a15..3a34029 100644 --- a/openapi-overlay.yaml +++ b/openapi-overlay.yaml @@ -22,3 +22,12 @@ actions: - circuit - gateset - qubits + + - target: $.components.schemas.CostModel.enum + remove: true + - target: $.components.schemas.CostModel + update: + enum: + - quantum_compute_time + - execution_time + - 2QGE_operations