diff --git a/changelog.d/14.changed.md b/changelog.d/14.changed.md new file mode 100644 index 0000000..a3cd9ac --- /dev/null +++ b/changelog.d/14.changed.md @@ -0,0 +1 @@ +Breaking: Align UEC Envelope 1.0 schemas/models and update all adapters to emit consistent producer/device/program/execution snapshots and unified artifact references. diff --git a/docs/concepts/uec.md b/docs/concepts/uec.md index 265d7fc..b9ce1d0 100644 --- a/docs/concepts/uec.md +++ b/docs/concepts/uec.md @@ -8,16 +8,38 @@ To keep runs reproducible and comparable, adapters produce a standardized **Exec ``` ExecutionEnvelope -├── device: DeviceSnapshot # Backend state and calibration -├── program: ProgramSnapshot # Circuit artifacts and hashes -├── execution: ExecutionSnapshot # Job metadata and settings -└── result: ResultSnapshot # Normalized measurement results +├── schema: "devqubit.envelope/1.0" +├── envelope_id, created_at # Envelope metadata +├── producer: ProducerInfo # SDK stack + versions +├── device: DeviceSnapshot # Backend state and calibration +├── program: ProgramSnapshot # Circuit artifacts and hashes +├── execution: ExecutionSnapshot # Job metadata and settings +└── result: ResultSnapshot # Normalized results (items[]) ``` The envelope is stored as an artifact with role `envelope` (typically kind `devqubit.envelope.json`). +The envelope schema is `devqubit.envelope/1.0` and requires `envelope_id`, `created_at`, `producer`, and `result`. + + ## Snapshots +### ProducerInfo + +Captures the complete SDK/toolchain stack that produced the envelope: + +| Field | Description | +|-------|-------------| +| `name` | Producer name (always `"devqubit"`) | +| `engine_version` | devqubit-engine version | +| `adapter` | Adapter package name (e.g., `devqubit-qiskit`) | +| `adapter_version` | Adapter version | +| `sdk` | Lowest/primary SDK (e.g., `qiskit`, `braket-sdk`, `cirq`) | +| `sdk_version` | Primary SDK version | +| `frontends` | Ordered SDK stack from highest to lowest layer | +| `build` | Optional build identifier (commit/dirty flag) | + + ### DeviceSnapshot Captures backend state at execution time: @@ -25,8 +47,8 @@ Captures backend state at execution time: | Field | Description | |-------|-------------| | `backend_name` | Backend identifier (e.g., "ibm_brisbane", "aer_simulator") | -| `backend_type` | "simulator" or "hardware" | -| `provider` | Provider name (e.g., "ibm_quantum", "aer") | +| `backend_type` | Backend type (e.g., `"hardware"`, `"simulator"`, `"emulator"`, `"unknown"`) | +| `provider` | Physical provider (e.g., `"ibm_quantum"`, `"aws_braket"`, `"local"`) | | `num_qubits` | Number of qubits | | `connectivity` | Qubit coupling map as edge list | | `native_gates` | Supported gate set | @@ -50,8 +72,8 @@ Captures circuit/program artifacts: Each artifact in `logical`/`physical` includes: - `format`: Circuit format (QPY, QASM3, etc.) -- `artifact_ref`: Reference to stored artifact -- `circuit_index`: Index in multi-circuit batch +- `ref`: Reference to stored artifact +- `index`: Index in multi-circuit batch - `name`: Circuit name (if available) ### ExecutionSnapshot @@ -66,22 +88,23 @@ Captures submission and job metadata: | `execution_count` | Execution counter within run | | `transpilation` | Transpilation info (mode, transpiled_by) | | `options` | Raw execution options (args, kwargs) | -| `sdk` | SDK used for execution | +| `sdk` | Optional legacy field (prefer `producer.sdk` / `producer.frontends`) | ### ResultSnapshot -Captures normalized execution results: +Captures normalized execution results (always as a list of per-item results): | Field | Description | |-------|-------------| -| `result_type` | Type of result (counts, quasi_dist, expectation, etc.) | -| `raw_result_ref` | Reference to full serialized result artifact | -| `counts` | Normalized measurement counts per circuit | -| `num_experiments` | Number of experiments in result | -| `success` | Whether execution succeeded | -| `error_message` | Error message if failed | +| `success` | Overall execution success | +| `status` | `"completed"`, `"failed"`, `"cancelled"`, or `"partial"` | +| `items` | List of `ResultItem` (one per circuit/parameter-set) | +| `error` | Structured error info when failed | +| `raw_result_ref` | Reference to the full serialized SDK result (optional) | | `metadata` | Additional result metadata | +Each `ResultItem` may contain one primary payload, e.g. `counts`, `quasi_probability`, or `expectation`. If `counts` are present, they include `format` metadata (source SDK + bit ordering) to make results comparable across SDKs. + ## Why UEC matters The Uniform Execution Contract makes it easier to: @@ -135,10 +158,17 @@ if envelope_artifact: device = envelope["device"] print(f"Backend: {device['backend_name']}") print(f"Qubits: {device['num_qubits']}") - - # Access results - for counts in envelope["result"]["counts"]: - print(f"Circuit {counts['circuit_index']}: {counts['counts']}") + # Access results (per-item) + for item in envelope["result"]["items"]: + if "counts" in item: + counts = item["counts"]["counts"] + print(f"Item {item['item_index']}: {counts}") + elif "quasi_probability" in item: + dist = item["quasi_probability"]["distribution"] + print(f"Item {item['item_index']} quasi: {dist}") + elif "expectation" in item: + value = item["expectation"]["value"] + print(f"Item {item['item_index']} expval: {value}") ``` -See {doc}`../guides/adapters` for what each SDK adapter captures. +See `../guides/adapters` for what each SDK adapter captures. diff --git a/docs/guides/adapters.md b/docs/guides/adapters.md index 77bf279..e11046d 100644 --- a/docs/guides/adapters.md +++ b/docs/guides/adapters.md @@ -42,7 +42,7 @@ All adapters produce a standardized **ExecutionEnvelope** containing four canoni | Snapshot | Description | |----------|-------------| -| `DeviceSnapshot` | Backend state, calibration, topology, and SDK versions | +| `DeviceSnapshot` | Backend state, calibration, topology, and provider properties | | `ProgramSnapshot` | Logical and physical circuit artifacts with hashes | | `ExecutionSnapshot` | Submission metadata, transpilation info, job IDs | | `ResultSnapshot` | Normalized measurement counts or expectation values | @@ -92,9 +92,9 @@ with track(project="bell-state") as run: | QPY binary | `qiskit.qpy.circuits` | `program` | | OpenQASM 3 | `source.openqasm3` | `program` | | Circuit diagram | `qiskit.circuits.diagram` | `program` | -| Counts | `result.counts.json` | `results` | -| Full result | `result.qiskit.result_json` | `results` | -| Raw backend properties | `device.qiskit.raw_properties.json` | `device_raw` | +| Counts | `result.counts.json` | `result` | +| Full result | `result.qiskit.result_json` | `result_raw` | +| Raw backend properties | `device.qiskit.raw_properties.json` | `device_snapshot` | | Execution envelope | `devqubit.envelope.json` | `envelope` | --- @@ -149,9 +149,9 @@ job = sampler.run([qc], | Transpiled QPY | `qiskit.qpy.circuits.transpiled` | `program` | | OpenQASM 3 | `source.openqasm3` | `program` | | PUB structure | `qiskit_runtime.pubs.json` | `program` | -| Sampler counts | `result.counts.json` | `results` | -| Estimator values | `result.qiskit_runtime.estimator.json` | `results` | -| Raw runtime properties | `device.qiskit_runtime.raw_properties.json` | `device_raw` | +| Sampler counts | `result.counts.json` | `result` | +| Estimator values | `result.qiskit_runtime.estimator.json` | `result` | +| Raw runtime properties | `device.qiskit_runtime.raw_properties.json` | `device_snapshot` | | Execution envelope | `devqubit.envelope.json` | `envelope` | --- @@ -177,9 +177,9 @@ with track(project="braket-experiment") as run: |----------|------|------| | OpenQASM 3 | `source.openqasm3` | `program` | | Circuit diagram | `braket.circuits.diagram` | `program` | -| Counts | `result.counts.json` | `results` | -| Raw result | `result.braket.raw.json` | `results` | -| Raw device properties | `device.braket.raw_properties.json` | `device_raw` | +| Counts | `result.counts.json` | `result` | +| Raw result | `result.braket.raw.json` | `result_raw` | +| Raw device properties | `device.braket.raw_properties.json` | `device_snapshot` | | Execution envelope | `devqubit.envelope.json` | `envelope` | --- @@ -225,8 +225,8 @@ with track(project="sweep") as run: |----------|------|------| | Cirq JSON | `cirq.circuit.json` | `program` | | Circuit diagram | `cirq.circuits.txt` | `program` | -| Counts | `result.counts.json` | `results` | -| Raw device properties | `device.cirq.raw_properties.json` | `device_raw` | +| Counts | `result.counts.json` | `result` | +| Raw device properties | `device.cirq.raw_properties.json` | `device_snapshot` | | Execution envelope | `devqubit.envelope.json` | `envelope` | --- @@ -261,8 +261,8 @@ with track(project="vqe") as run: |----------|------|------| | Tape JSON | `pennylane.tapes.json` | `program` | | Tape diagram | `pennylane.tapes.txt` | `program` | -| Results | `result.pennylane.output.json` | `results` | -| Raw device properties | `device.pennylane.raw_properties.json` | `device_raw` | +| Results | `result.pennylane.output.json` | `result` | +| Raw device properties | `device.pennylane.raw_properties.json` | `device_snapshot` | | Execution envelope | `devqubit.envelope.json` | `envelope` | ### Multi-Layer Stack @@ -400,7 +400,7 @@ with track(project="custom-sdk") as run: run.log_json( name="counts", obj={"00": 500, "11": 500}, - role="results", + role="result", kind="result.counts.json", ) ``` @@ -411,6 +411,7 @@ For full UEC compliance, create an ExecutionEnvelope: ```python from devqubit.uec import ( + ProducerInfo, DeviceSnapshot, ExecutionEnvelope, ExecutionSnapshot, @@ -418,11 +419,19 @@ from devqubit.uec import ( ResultSnapshot, ) +producer = ProducerInfo.create( + adapter="devqubit-custom", + adapter_version="0.1.0", + sdk="custom-sdk", + sdk_version="1.0.0", + frontends=["custom-sdk"], +) + # Build snapshots device = DeviceSnapshot( backend_name="custom_device", backend_type="simulator", - provider="custom", + provider="local", captured_at=utc_now_iso(), ) @@ -435,13 +444,14 @@ program = ProgramSnapshot( execution = ExecutionSnapshot( submitted_at=utc_now_iso(), shots=1000, - sdk="custom", ) # Create and log envelope envelope = ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="custom", + schema_version="devqubit.envelope/1.0", + envelope_id="01J0EXAMPLEENVELOPEID0000", + created_at=utc_now_iso(), + producer=producer, device=device, program=program, execution=execution, diff --git a/packages/devqubit-braket/src/devqubit_braket/adapter.py b/packages/devqubit-braket/src/devqubit_braket/adapter.py index 0f23b22..027ae72 100644 --- a/packages/devqubit-braket/src/devqubit_braket/adapter.py +++ b/packages/devqubit-braket/src/devqubit_braket/adapter.py @@ -6,7 +6,7 @@ Provides integration with Amazon Braket devices, enabling automatic tracking of quantum circuit execution, results, and device configurations using the -Uniform Execution Contract (UEC). +Uniform Execution Contract (UEC) 1.0. Example ------- @@ -25,45 +25,28 @@ from __future__ import annotations import hashlib -import inspect import logging from dataclasses import dataclass, field from typing import Any -from devqubit_braket.results import extract_counts_payload -from devqubit_braket.serialization import ( - BraketCircuitSerializer, - circuits_to_text, - is_braket_circuit, - serialize_openqasm, +from devqubit_braket.envelope import ( + create_envelope, + log_submission_failure, ) -from devqubit_braket.snapshot import create_device_snapshot -from devqubit_braket.utils import braket_version, extract_task_id, get_backend_name -from devqubit_engine.circuit.models import CircuitFormat +from devqubit_braket.serialization import is_braket_circuit +from devqubit_braket.tracked import TrackedTask, TrackedTaskBatch +from devqubit_braket.utils import extract_task_id, get_backend_name from devqubit_engine.core.run import Run -from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.envelope import ExecutionEnvelope -from devqubit_engine.uec.execution import ExecutionSnapshot -from devqubit_engine.uec.program import ( - ProgramArtifact, - ProgramSnapshot, - TranspilationInfo, -) -from devqubit_engine.uec.result import NormalizedCounts, ResultSnapshot -from devqubit_engine.uec.types import ( - ArtifactRef, - ProgramRole, - ResultType, - TranspilationMode, -) from devqubit_engine.utils.serialization import to_jsonable from devqubit_engine.utils.time_utils import utc_now_iso logger = logging.getLogger(__name__) -# Module-level serializer instance -_serializer = BraketCircuitSerializer() +# ============================================================================ +# ProgramSet handling utilities +# ============================================================================ def _is_program_set(obj: Any) -> bool: @@ -215,6 +198,11 @@ def _materialize_task_spec( return task_specification, [task_specification], True, None +# ============================================================================ +# Circuit hashing +# ============================================================================ + + def _compute_circuit_hash(circuits: list[Any]) -> str | None: """ Compute a content hash for circuits. @@ -287,810 +275,9 @@ def _compute_circuit_hash(circuits: list[Any]) -> str | None: return f"sha256:{hashlib.sha256(payload).hexdigest()}" -def _serialize_and_log_circuits( - tracker: Run, - circuits: list[Any], - device_name: str, -) -> list[ArtifactRef]: - """ - Serialize circuits and log as artifacts. - - Logs both JAQCD and OpenQASM formats for comprehensive coverage. - - Parameters - ---------- - tracker : Run - Tracker instance. - circuits : list - List of Braket circuits. - device_name : str - Backend name for metadata. - - Returns - ------- - list of ArtifactRef - References to logged circuit artifacts. - """ - artifact_refs: list[ArtifactRef] = [] - meta = { - "backend_name": device_name, - "braket_version": braket_version(), - } - - for i, circuit in enumerate(circuits): - # Serialize JAQCD (native format) - try: - jaqcd_data = _serializer.serialize(circuit, CircuitFormat.JAQCD, index=i) - ref = tracker.log_bytes( - kind="braket.ir.jaqcd", - data=jaqcd_data.as_bytes(), - media_type="application/json", - role="program", - meta={**meta, "index": i}, - ) - if ref: - artifact_refs.append(ref) - except Exception as e: - logger.debug("Failed to serialize circuit %d to JAQCD: %s", i, e) - - # Serialize OpenQASM (canonical format, better for diffing) - try: - qasm_data = serialize_openqasm(circuit, index=i) - tracker.log_bytes( - kind="braket.ir.openqasm", - data=qasm_data.as_bytes(), - media_type="text/x-qasm; charset=utf-8", - role="program", - meta={**meta, "index": i, "format": "openqasm3"}, - ) - except Exception as e: - logger.debug("Failed to serialize circuit %d to OpenQASM: %s", i, e) - - # Log circuit diagrams (human-readable) - try: - diagram_text = circuits_to_text(circuits) - tracker.log_bytes( - kind="braket.circuits.diagram", - data=diagram_text.encode("utf-8"), - media_type="text/plain; charset=utf-8", - role="program", - meta={"num_circuits": len(circuits)}, - ) - except Exception as e: - logger.debug("Failed to generate circuit diagrams: %s", e) - - return artifact_refs - - -def _create_program_snapshot( - circuits: list[Any], - artifact_refs: list[ArtifactRef], - circuit_hash: str | None, -) -> ProgramSnapshot: - """ - Create a ProgramSnapshot from circuits and their artifact refs. - - Parameters - ---------- - circuits : list - List of Braket circuits. - artifact_refs : list of ArtifactRef - References to logged circuit artifacts. - circuit_hash : str or None - Circuit structure hash. - - Returns - ------- - ProgramSnapshot - Program snapshot with logical artifacts. - """ - logical_artifacts: list[ProgramArtifact] = [] - - for i, ref in enumerate(artifact_refs): - circuit_name = None - if i < len(circuits): - circuit_name = getattr(circuits[i], "name", None) - - logical_artifacts.append( - ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="jaqcd", - name=circuit_name or f"circuit_{i}", - index=i, - ) - ) - - return ProgramSnapshot( - logical=logical_artifacts, - physical=[], # Braket doesn't expose transpiled circuits - program_hash=circuit_hash, - num_circuits=len(circuits), - ) - - -def _create_execution_snapshot( - shots: int | None, - task_ids: list[str], - submitted_at: str, - options: dict[str, Any] | None = None, -) -> ExecutionSnapshot: - """ - Create an ExecutionSnapshot for a Braket task submission. - - Parameters - ---------- - shots : int or None - Number of shots (None means provider default). - task_ids : list of str - Task identifiers. - submitted_at : str - ISO 8601 submission timestamp. - options : dict, optional - Additional execution options. - - Returns - ------- - ExecutionSnapshot - Execution metadata snapshot. - """ - return ExecutionSnapshot( - submitted_at=submitted_at, - shots=shots, - task_ids=task_ids, - execution_count=len(task_ids) if task_ids else 1, - transpilation=TranspilationInfo( - mode=TranspilationMode.MANAGED, - transpiled_by="provider", - ), - options=options or {}, - sdk="braket", - ) - - -def _create_result_snapshot( - result: Any, - raw_result_ref: ArtifactRef | None, - shots: int | None, - error_message: str | None = None, -) -> ResultSnapshot: - """ - Create a ResultSnapshot from Braket result. - - Parameters - ---------- - result : Any - Braket result object (may be None on failure). - raw_result_ref : ArtifactRef or None - Reference to raw result artifact. - shots : int or None - Number of shots used. - error_message : str or None - Error message if execution failed. - - Returns - ------- - ResultSnapshot - Result snapshot with normalized counts and success status. - """ - normalized_counts: list[NormalizedCounts] = [] - success = False - result_type = ResultType.COUNTS - - if result is not None and error_message is None: - # Check if result is already a combined payload dict (from batch) - if isinstance(result, dict) and "experiments" in result: - counts_payload = result - else: - counts_payload = extract_counts_payload(result) - - if counts_payload and counts_payload.get("experiments"): - for exp in counts_payload["experiments"]: - counts = exp.get("counts", {}) - normalized_counts.append( - NormalizedCounts( - circuit_index=exp.get("index", 0), - counts=counts if counts else {}, - shots=shots, - name=exp.get("name"), - ) - ) - - # Fallback: if we have a result but no experiments extracted - if not normalized_counts: - batch_size = result.get("batch_size", 1) if isinstance(result, dict) else 1 - for i in range(batch_size): - normalized_counts.append( - NormalizedCounts(circuit_index=i, counts={}, shots=shots) - ) - - # Success = we have actual non-empty counts - success = any(nc.counts for nc in normalized_counts) - - # For shots=0 (analytical), may get statevector/other instead of counts - if not success and shots == 0: - if hasattr(result, "values") or hasattr(result, "result_types"): - result_type = ResultType.OTHER - success = True - - # Build ResultSnapshot - handle error_message field defensively - snapshot_kwargs: dict[str, Any] = { - "result_type": result_type, - "raw_result_ref": raw_result_ref, - "counts": normalized_counts, - "num_experiments": len(normalized_counts), - "success": success, - } - - # Only add error_message if the field exists in ResultSnapshot - try: - sig = inspect.signature(ResultSnapshot) - if "error_message" in sig.parameters: - snapshot_kwargs["error_message"] = error_message - except Exception: - pass - - return ResultSnapshot(**snapshot_kwargs) - - -def _create_envelope( - tracker: Run, - device: Any, - circuits: list[Any], - shots: int | None, - task_ids: list[str], - submitted_at: str, - circuit_hash: str | None, - options: dict[str, Any] | None = None, -) -> ExecutionEnvelope: - """ - Create and log a complete ExecutionEnvelope (pre-result). - - Parameters - ---------- - tracker : Run - Tracker instance. - device : Any - Braket device. - circuits : list - List of circuits. - shots : int or None - Number of shots. - task_ids : list of str - Task identifiers. - submitted_at : str - Submission timestamp. - circuit_hash : str or None - Circuit hash. - options : dict, optional - Execution options. - - Returns - ------- - ExecutionEnvelope - Envelope with device, program, and execution snapshots. - """ - device_name = get_backend_name(device=device) - - # Create device snapshot with tracker for raw_properties logging - try: - device_snapshot = create_device_snapshot(device=device, tracker=tracker) - except Exception as e: - logger.warning( - "Failed to create device snapshot: %s. Using minimal snapshot.", e - ) - # Create minimal snapshot on failure - device_snapshot = DeviceSnapshot( - captured_at=utc_now_iso(), - backend_name=device_name, - backend_type="unknown", - provider="braket", - sdk_versions={"braket": braket_version()}, - ) - - # Update tracker record - tracker.record["device_snapshot"] = { - "sdk": "braket", - "backend_name": device_name, - "backend_type": device_snapshot.backend_type, - "provider": device_snapshot.provider, - "captured_at": device_snapshot.captured_at, - "num_qubits": device_snapshot.num_qubits, - "calibration_summary": device_snapshot.get_calibration_summary(), - } - - # Log circuits and get artifact refs - artifact_refs = _serialize_and_log_circuits( - tracker=tracker, - circuits=circuits, - device_name=device_name, - ) - - # Create program snapshot - program_snapshot = _create_program_snapshot( - circuits=circuits, - artifact_refs=artifact_refs, - circuit_hash=circuit_hash, - ) - - # Create execution snapshot - execution_snapshot = _create_execution_snapshot( - shots=shots, - task_ids=task_ids, - submitted_at=submitted_at, - options=options, - ) - - return ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="braket", - created_at=utc_now_iso(), - device=device_snapshot, - program=program_snapshot, - execution=execution_snapshot, - result=None, # Will be filled when result() is called - ) - - -def _finalize_envelope_with_result( - tracker: Run, - envelope: ExecutionEnvelope, - result: Any, - device_name: str, - shots: int | None, - error_message: str | None = None, -) -> ExecutionEnvelope: - """ - Finalize envelope with result and log it. - - This function never raises exceptions - tracking should never crash - user experiments. Validation errors are logged but execution continues. - - Parameters - ---------- - tracker : Run - Tracker instance. - envelope : ExecutionEnvelope - Envelope to finalize. - result : Any - Braket result object (may be None on failure). - device_name : str - Device name. - shots : int or None - Number of shots. - error_message : str or None - Error message if execution failed. - - Returns - ------- - ExecutionEnvelope - Finalized envelope. - - Raises - ------ - ValueError - If envelope is None. - """ - if envelope is None: - raise ValueError("Cannot finalize None envelope") - - # Log raw result and get ref - raw_result_ref: ArtifactRef | None = None - if result is not None: - try: - result_payload = to_jsonable(result) - except Exception: - result_payload = {"repr": repr(result)[:2000]} - - try: - raw_result_ref = tracker.log_json( - name="braket.result", - obj=result_payload, - role="results", - kind="result.braket.raw.json", - ) - except Exception as e: - logger.warning("Failed to log raw result: %s", e) - elif error_message: - try: - tracker.log_json( - name="braket.error", - obj={"error": error_message, "timestamp": utc_now_iso()}, - role="results", - kind="result.braket.error.json", - ) - except Exception as e: - logger.warning("Failed to log error: %s", e) - - # Create result snapshot - result_snapshot = _create_result_snapshot( - result, raw_result_ref, shots, error_message - ) - - # Update execution snapshot with completion time - if envelope.execution: - envelope.execution.completed_at = utc_now_iso() - - # Add result to envelope - envelope.result = result_snapshot - - # Extract counts for separate logging - counts_payload = None - if result is not None: - try: - counts_payload = extract_counts_payload(result) - except Exception as e: - logger.debug("Failed to extract counts payload: %s", e) - - # Validate and log envelope - try: - tracker.log_envelope(envelope=envelope) - except Exception as e: - logger.warning("Failed to log envelope: %s", e) - - # Log normalized counts - if counts_payload is not None: - try: - tracker.log_json( - name="counts", - obj=counts_payload, - role="results", - kind="result.counts.json", - ) - except Exception as e: - logger.debug("Failed to log counts: %s", e) - - # Update tracker record - tracker.record["results"] = { - "completed_at": utc_now_iso(), - "backend_name": device_name, - "num_experiments": result_snapshot.num_experiments, - "result_type": result_snapshot.result_type.value, - "success": result_snapshot.success, - } - if error_message: - tracker.record["results"]["error"] = error_message - - logger.debug("Logged execution envelope for %s", device_name) - - return envelope - - -def _log_submission_failure( - tracker: Run, - device_name: str, - error: Exception, - circuits: list[Any], - shots: int | None, - submitted_at: str, -) -> None: - """ - Log a task submission failure. - - Parameters - ---------- - tracker : Run - Tracker instance. - device_name : str - Device name. - error : Exception - The exception that occurred. - circuits : list - Circuits that were being submitted. - shots : int or None - Requested shots. - submitted_at : str - Submission timestamp. - """ - error_info = { - "type": "submission_failure", - "error_type": type(error).__name__, - "error_message": str(error), - "device_name": device_name, - "num_circuits": len(circuits), - "shots": shots, - "submitted_at": submitted_at, - "failed_at": utc_now_iso(), - } - - tracker.log_json( - name="submission_failure", - obj=error_info, - role="error", - kind="devqubit.submission_failure.json", - ) - - tracker.record["submission_failure"] = error_info - logger.warning("Task submission failed on %s: %s", device_name, error) - - -def _combine_batch_results(results_list: list[Any]) -> dict[str, Any]: - """ - Combine batch results into a single structure for logging. - - Parameters - ---------- - results_list : list - List of individual result objects. - - Returns - ------- - dict - Combined result structure. - """ - experiments: list[dict[str, Any]] = [] - - for i, result in enumerate(results_list): - if result is None: - experiments.append({"index": i, "status": "failed", "counts": {}}) - continue - - counts_payload = extract_counts_payload(result) - if counts_payload and counts_payload.get("experiments"): - for exp in counts_payload["experiments"]: - exp_copy = dict(exp) - exp_copy["batch_index"] = i - experiments.append(exp_copy) - else: - experiments.append({"index": i, "batch_index": i, "counts": {}}) - - return {"experiments": experiments, "batch_size": len(results_list)} - - -@dataclass -class TrackedTask: - """ - Wrapper for Braket task that tracks result retrieval. - - Intercepts `result()` calls to finalize the execution envelope - with result data. Handles exceptions gracefully with failure logging. - - Parameters - ---------- - task : Any - Original Braket task instance. - tracker : Run - Tracker instance for logging. - device_name : str - Name of the device that created this task. - envelope : ExecutionEnvelope or None - Envelope to finalize with results. - shots : int or None - Number of shots for this execution. - should_log_results : bool - Whether to log results for this task. - """ - - task: Any - tracker: Run - device_name: str - envelope: ExecutionEnvelope | None = None - shots: int | None = None - should_log_results: bool = True - _result_logged: bool = field(default=False, init=False, repr=False) - - def result(self, *args: Any, **kwargs: Any) -> Any: - """ - Retrieve task result and finalize envelope. - - Handles exceptions gracefully, logging failure information - before re-raising. - - Parameters - ---------- - *args : Any - Positional arguments passed to underlying result(). - **kwargs : Any - Keyword arguments passed to underlying result(). - - Returns - ------- - Any - Braket result object. - - Raises - ------ - Exception - Re-raises any exception from underlying result() after logging. - """ - result = None - error_message: str | None = None - - try: - result = self.task.result(*args, **kwargs) - except Exception as e: - error_message = f"{type(e).__name__}: {e}" - logger.warning("Task result() failed on %s: %s", self.device_name, e) - - # Log failure even if we re-raise - if self.should_log_results and self.envelope and not self._result_logged: - self._result_logged = True - try: - _finalize_envelope_with_result( - tracker=self.tracker, - envelope=self.envelope, - result=None, - device_name=self.device_name, - shots=self.shots, - error_message=error_message, - ) - except Exception as log_err: - logger.warning( - "Failed to log error envelope for %s: %s", - self.device_name, - log_err, - ) - raise - - # Log successful result - if self.should_log_results and self.envelope and not self._result_logged: - self._result_logged = True - try: - _finalize_envelope_with_result( - tracker=self.tracker, - envelope=self.envelope, - result=result, - device_name=self.device_name, - shots=self.shots, - ) - logger.debug("Finalized envelope for task on %s", self.device_name) - except Exception as log_err: - logger.warning( - "Failed to finalize envelope for %s: %s", - self.device_name, - log_err, - ) - # Record error in tracker for visibility - self.tracker.record.setdefault("warnings", []).append( - { - "type": "result_logging_failed", - "message": str(log_err), - "device_name": self.device_name, - } - ) - - return result - - def __getattr__(self, name: str) -> Any: - """Delegate attribute access to wrapped task.""" - return getattr(self.task, name) - - def __repr__(self) -> str: - """Return string representation.""" - task_id = extract_task_id(self.task) or "unknown" - return f"TrackedTask(device={self.device_name!r}, task_id={task_id!r})" - - -@dataclass -class TrackedTaskBatch: - """ - Wrapper for Braket task batch that tracks result retrieval. - - Wraps AwsQuantumTaskBatch to intercept `results()` calls and log - all results with proper handling of partial failures. - - Parameters - ---------- - batch : Any - Original Braket task batch instance. - tracker : Run - Tracker instance for logging. - device_name : str - Name of the device that created this batch. - envelope : ExecutionEnvelope or None - Envelope to finalize with results. - shots : int or None - Number of shots for this execution. - should_log_results : bool - Whether to log results for this batch. - """ - - batch: Any - tracker: Run - device_name: str - envelope: ExecutionEnvelope | None = None - shots: int | None = None - should_log_results: bool = True - _results_logged: bool = field(default=False, init=False, repr=False) - - def results(self, *args: Any, **kwargs: Any) -> list[Any]: - """ - Retrieve batch results and finalize envelope. - - Handles partial failures (None results for failed tasks). - - Parameters - ---------- - *args : Any - Positional arguments passed to underlying results(). - **kwargs : Any - Keyword arguments passed to underlying results(). - - Returns - ------- - list - List of Braket result objects (may contain None for failed tasks). - """ - results_list: list[Any] = [] - error_message: str | None = None - - try: - results_list = self.batch.results(*args, **kwargs) - except Exception as e: - error_message = f"{type(e).__name__}: {e}" - logger.warning("Batch results() failed on %s: %s", self.device_name, e) - - if self.should_log_results and self.envelope and not self._results_logged: - self._results_logged = True - try: - _finalize_envelope_with_result( - self.tracker, - self.envelope, - None, - self.device_name, - self.shots, - error_message=error_message, - ) - except Exception as log_err: - logger.warning( - "Failed to log error envelope for batch %s: %s", - self.device_name, - log_err, - ) - raise - - # Check for partial failures (None in results) - failed_count = sum(1 for r in results_list if r is None) - if failed_count > 0: - logger.warning( - "Batch on %s: %d/%d tasks failed", - self.device_name, - failed_count, - len(results_list), - ) - error_message = ( - f"Partial failure: {failed_count}/{len(results_list)} tasks failed" - ) - - # Aggregate successful results for logging - if self.should_log_results and self.envelope and not self._results_logged: - self._results_logged = True - try: - combined_result = _combine_batch_results(results_list) - - _finalize_envelope_with_result( - self.tracker, - self.envelope, - combined_result, - self.device_name, - self.shots, - error_message=error_message if failed_count > 0 else None, - ) - logger.debug("Finalized envelope for batch on %s", self.device_name) - except Exception as log_err: - logger.warning( - "Failed to finalize envelope for batch %s: %s", - self.device_name, - log_err, - ) - # Record error in tracker for visibility - self.tracker.record.setdefault("warnings", []).append( - { - "type": "batch_result_logging_failed", - "message": str(log_err), - "device_name": self.device_name, - } - ) - - return results_list - - def __getattr__(self, name: str) -> Any: - """Delegate attribute access to wrapped batch.""" - return getattr(self.batch, name) - - def __repr__(self) -> str: - """Return string representation.""" - return f"TrackedTaskBatch(device={self.device_name!r})" +# ============================================================================ +# TrackedDevice - wraps Braket devices with tracking +# ============================================================================ @dataclass @@ -1199,7 +386,7 @@ def run( task = self.device.run(run_payload, shots=shots, *args, **kwargs) except Exception as e: if should_log and circuits_for_logging: - _log_submission_failure( + log_submission_failure( self.tracker, device_name, e, @@ -1216,7 +403,7 @@ def run( # Create envelope if logging envelope: ExecutionEnvelope | None = None if should_log and circuits_for_logging: - envelope = _create_envelope( + envelope = create_envelope( tracker=self.tracker, device=self.device, circuits=circuits_for_logging, @@ -1224,6 +411,7 @@ def run( task_ids=task_ids, submitted_at=submitted_at, circuit_hash=circuit_hash, + execution_index=exec_count, options=options if options else None, ) @@ -1232,10 +420,10 @@ def run( self._logged_execution_count += 1 - # Set tracker tags/params + # Set tracker tags/params (P1 fix: provider is platform, not SDK) self.tracker.set_tag("backend_name", device_name) - self.tracker.set_tag("provider", "braket") - self.tracker.set_tag("adapter", "braket") + self.tracker.set_tag("provider", "aws_braket") + self.tracker.set_tag("adapter", "devqubit-braket") if shots is not None: self.tracker.log_param("shots", int(shots)) @@ -1245,7 +433,7 @@ def run( self.tracker.record["backend"] = { "name": device_name, "type": self.device.__class__.__name__, - "provider": "braket", + "provider": "aws_braket", } self.tracker.record["execute"] = { @@ -1346,7 +534,7 @@ def run_batch( ) except Exception as e: if should_log and circuits_for_logging: - _log_submission_failure( + log_submission_failure( self.tracker, device_name, e, @@ -1359,7 +547,7 @@ def run_batch( # Create envelope if logging envelope: ExecutionEnvelope | None = None if should_log and circuits_for_logging: - envelope = _create_envelope( + envelope = create_envelope( tracker=self.tracker, device=self.device, circuits=circuits_for_logging, @@ -1367,6 +555,7 @@ def run_batch( task_ids=[], # Batch doesn't have a single ID upfront submitted_at=submitted_at, circuit_hash=circuit_hash, + execution_index=exec_count, options=options, ) @@ -1375,10 +564,10 @@ def run_batch( self._logged_execution_count += 1 - # Set tracker tags/params + # Set tracker tags/params (P1 fix: provider is platform, not SDK) self.tracker.set_tag("backend_name", device_name) - self.tracker.set_tag("provider", "braket") - self.tracker.set_tag("adapter", "braket") + self.tracker.set_tag("provider", "aws_braket") + self.tracker.set_tag("adapter", "devqubit-braket") self.tracker.set_tag("batch_execution", "true") if shots is not None: @@ -1390,7 +579,7 @@ def run_batch( self.tracker.record["backend"] = { "name": device_name, "type": self.device.__class__.__name__, - "provider": "braket", + "provider": "aws_braket", } self.tracker.record["execute"] = { @@ -1459,6 +648,11 @@ def __repr__(self) -> str: return f"TrackedDevice(device={device_name!r}, run_id={self.tracker.run_id!r})" +# ============================================================================ +# BraketAdapter - main adapter class +# ============================================================================ + + class BraketAdapter: """ Adapter for integrating Braket devices with devqubit tracking. @@ -1515,7 +709,7 @@ def describe_executor(self, device: Any) -> dict[str, Any]: return { "name": get_backend_name(device), "type": device.__class__.__name__, - "provider": "braket", + "provider": "aws_braket", } def wrap_executor( diff --git a/packages/devqubit-braket/src/devqubit_braket/envelope.py b/packages/devqubit-braket/src/devqubit_braket/envelope.py new file mode 100644 index 0000000..d9e6956 --- /dev/null +++ b/packages/devqubit-braket/src/devqubit_braket/envelope.py @@ -0,0 +1,662 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Envelope creation for Braket adapter. + +Creates UEC 1.0 compliant ExecutionEnvelopes with proper snapshots +for device, program, execution, and result data. + +Notes +----- +Braket uses big-endian bit ordering (qubit 0 = leftmost bit). +In UEC terminology this is ``cbit0_left``. The canonical UEC format +is ``cbit0_right`` (little-endian, like Qiskit). + +By default, this adapter preserves Braket's native format and records +``transformed=False`` in CountsFormat. Consumers should check the +``bit_order`` field and transform if needed for cross-SDK comparison. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING, Any + +from devqubit_braket.serialization import ( + BraketCircuitSerializer, + circuits_to_text, + serialize_openqasm, +) +from devqubit_braket.snapshot import create_device_snapshot +from devqubit_braket.utils import braket_version, get_adapter_version +from devqubit_engine.circuit.models import CircuitFormat +from devqubit_engine.uec.device import DeviceSnapshot +from devqubit_engine.uec.envelope import ExecutionEnvelope +from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.producer import ProducerInfo +from devqubit_engine.uec.program import ( + ProgramArtifact, + ProgramSnapshot, + TranspilationInfo, +) +from devqubit_engine.uec.result import ( + CountsFormat, + ResultError, + ResultItem, + ResultSnapshot, +) +from devqubit_engine.uec.types import ( + ArtifactRef, + ProgramRole, + TranspilationMode, +) +from devqubit_engine.utils.serialization import to_jsonable +from devqubit_engine.utils.time_utils import utc_now_iso + + +if TYPE_CHECKING: + from devqubit_engine.core.run import Run + + +logger = logging.getLogger(__name__) + +# Module-level serializer instance +_serializer = BraketCircuitSerializer() + + +def _get_braket_counts_format(transformed: bool = False) -> dict[str, Any]: + """ + Get CountsFormat metadata for Braket results. + + Braket uses big-endian bit order (qubit 0 = leftmost bit), + which corresponds to ``cbit0_left`` in UEC canonical terminology. + + Parameters + ---------- + transformed : bool + Whether counts have been transformed to canonical ``cbit0_right`` format. + Default False - Braket's native format is preserved. + + Returns + ------- + dict + CountsFormat as dictionary for JSON serialization. + + Notes + ----- + UEC canonical format is ``cbit0_right`` (like Qiskit). When ``transformed=False``, + consumers must reverse bitstrings themselves for cross-SDK comparison. + """ + return CountsFormat( + source_sdk="braket", + source_key_format="bitstring", + bit_order="cbit0_left", # Braket native: big-endian + transformed=transformed, + ).to_dict() + + +def serialize_and_log_circuits( + tracker: Run, + circuits: list[Any], + device_name: str, +) -> list[ArtifactRef]: + """ + Serialize circuits and log as artifacts. + + Logs both JAQCD and OpenQASM formats for comprehensive coverage. + + Parameters + ---------- + tracker : Run + Tracker instance. + circuits : list + List of Braket circuits. + device_name : str + Backend name for metadata. + + Returns + ------- + list of ArtifactRef + References to logged circuit artifacts. + """ + artifact_refs: list[ArtifactRef] = [] + meta = { + "backend_name": device_name, + "braket_version": braket_version(), + } + + for i, circuit in enumerate(circuits): + # Serialize JAQCD (native format) + try: + jaqcd_data = _serializer.serialize(circuit, CircuitFormat.JAQCD, index=i) + ref = tracker.log_bytes( + kind="braket.ir.jaqcd", + data=jaqcd_data.as_bytes(), + media_type="application/json", + role="program", + meta={**meta, "index": i}, + ) + if ref: + artifact_refs.append(ref) + except Exception as e: + logger.debug("Failed to serialize circuit %d to JAQCD: %s", i, e) + + # Serialize OpenQASM (canonical format, better for diffing) + try: + qasm_data = serialize_openqasm(circuit, index=i) + tracker.log_bytes( + kind="braket.ir.openqasm", + data=qasm_data.as_bytes(), + media_type="text/x-qasm; charset=utf-8", + role="program", + meta={**meta, "index": i, "format": "openqasm3"}, + ) + except Exception as e: + logger.debug("Failed to serialize circuit %d to OpenQASM: %s", i, e) + + # Log circuit diagrams (human-readable) + try: + diagram_text = circuits_to_text(circuits) + tracker.log_bytes( + kind="braket.circuits.diagram", + data=diagram_text.encode("utf-8"), + media_type="text/plain; charset=utf-8", + role="program", + meta={"num_circuits": len(circuits)}, + ) + except Exception as e: + logger.debug("Failed to generate circuit diagrams: %s", e) + + return artifact_refs + + +def create_program_snapshot( + circuits: list[Any], + artifact_refs: list[ArtifactRef], + circuit_hash: str | None, +) -> ProgramSnapshot: + """ + Create a ProgramSnapshot from circuits and their artifact refs. + + Parameters + ---------- + circuits : list + List of Braket circuits. + artifact_refs : list of ArtifactRef + References to logged circuit artifacts. + circuit_hash : str or None + Circuit structure hash. + + Returns + ------- + ProgramSnapshot + Program snapshot with logical artifacts. + """ + logical_artifacts: list[ProgramArtifact] = [] + + for i, ref in enumerate(artifact_refs): + circuit_name = None + if i < len(circuits): + circuit_name = getattr(circuits[i], "name", None) + + logical_artifacts.append( + ProgramArtifact( + ref=ref, + role=ProgramRole.LOGICAL, + format="jaqcd", + name=circuit_name or f"circuit_{i}", + index=i, + ) + ) + + return ProgramSnapshot( + logical=logical_artifacts, + physical=[], # Braket doesn't expose transpiled circuits + program_hash=circuit_hash, + num_circuits=len(circuits), + ) + + +def create_execution_snapshot( + shots: int | None, + task_ids: list[str], + submitted_at: str, + execution_index: int = 1, + options: dict[str, Any] | None = None, +) -> ExecutionSnapshot: + """ + Create an ExecutionSnapshot for a Braket task submission. + + Parameters + ---------- + shots : int or None + Number of shots (None means provider default). + task_ids : list of str + Task identifiers. + submitted_at : str + ISO 8601 submission timestamp. + execution_index : int + Which execution this is (1-indexed sequence number). + options : dict, optional + Additional execution options. + + Returns + ------- + ExecutionSnapshot + Execution metadata snapshot. + """ + return ExecutionSnapshot( + submitted_at=submitted_at, + shots=shots, + job_ids=task_ids, + execution_count=execution_index, + transpilation=TranspilationInfo( + mode=TranspilationMode.MANAGED, + transpiled_by="provider", + ), + options=options or {}, + sdk="braket", + ) + + +def create_result_snapshot( + result: Any, + raw_result_ref: ArtifactRef | None, + shots: int | None, + error: Exception | None = None, +) -> ResultSnapshot: + """ + Create a ResultSnapshot from Braket result (UEC 1.0 format). + + Parameters + ---------- + result : Any + Braket result object (may be None on failure). + raw_result_ref : ArtifactRef or None + Reference to raw result artifact. + shots : int or None + Number of shots used. + error : Exception or None + Exception if execution failed. + + Returns + ------- + ResultSnapshot + Result snapshot with items list and success status. + """ + from devqubit_braket.results import extract_counts_payload + + items: list[ResultItem] = [] + success = False + status = "failed" + result_error: ResultError | None = None + + if error is not None: + result_error = ResultError( + type=type(error).__name__, + message=str(error), + ) + status = "failed" + elif result is not None: + # Check if result is already a combined payload dict (from batch) + if isinstance(result, dict) and "experiments" in result: + counts_payload = result + else: + counts_payload = extract_counts_payload(result) + + if counts_payload and counts_payload.get("experiments"): + format_dict = _get_braket_counts_format() + + for exp in counts_payload["experiments"]: + counts_data = exp.get("counts", {}) + item_success = bool(counts_data) + + # Build counts structure per UEC 1.0 schema + counts_obj = None + if counts_data: + counts_obj = { + "counts": counts_data, + "shots": shots or sum(counts_data.values()), + "format": format_dict, + } + + items.append( + ResultItem( + item_index=exp.get("index", len(items)), + success=item_success, + counts=counts_obj, + ) + ) + + # Success = at least one item with non-empty counts + success = any(item.success for item in items) + status = "completed" if success else "partial" + + # Fallback: if we have a result but no experiments extracted + if not items: + batch_size = result.get("batch_size", 1) if isinstance(result, dict) else 1 + for i in range(batch_size): + items.append( + ResultItem( + item_index=i, + success=False, + counts=None, + ) + ) + status = "partial" + + # For shots=0 (analytical), may get statevector/other instead of counts + if not success and shots == 0: + if hasattr(result, "values") or hasattr(result, "result_types"): + success = True + status = "completed" + + return ResultSnapshot( + success=success, + status=status, + items=items, + error=result_error, + raw_result_ref=raw_result_ref, + metadata={}, + ) + + +def create_envelope( + tracker: Run, + device: Any, + circuits: list[Any], + shots: int | None, + task_ids: list[str], + submitted_at: str, + circuit_hash: str | None, + execution_index: int = 1, + options: dict[str, Any] | None = None, +) -> ExecutionEnvelope: + """ + Create and log a complete ExecutionEnvelope (pre-result). + + Parameters + ---------- + tracker : Run + Tracker instance. + device : Any + Braket device. + circuits : list + List of circuits. + shots : int or None + Number of shots. + task_ids : list of str + Task identifiers. + submitted_at : str + Submission timestamp. + circuit_hash : str or None + Circuit hash. + execution_index : int + Which execution this is (1-indexed sequence number). + options : dict, optional + Execution options. + + Returns + ------- + ExecutionEnvelope + Envelope with device, program, and execution snapshots. + """ + from devqubit_braket.utils import get_backend_name + + device_name = get_backend_name(device=device) + + # Create device snapshot with tracker for raw_properties logging + try: + device_snapshot = create_device_snapshot(device=device, tracker=tracker) + except Exception as e: + logger.warning( + "Failed to create device snapshot: %s. Using minimal snapshot.", e + ) + device_snapshot = DeviceSnapshot( + captured_at=utc_now_iso(), + backend_name=device_name, + backend_type="simulator", + provider="aws_braket", + sdk_versions={"braket": braket_version()}, + ) + + # Update tracker record + tracker.record["device_snapshot"] = { + "sdk": "braket", + "backend_name": device_name, + "backend_type": device_snapshot.backend_type, + "provider": device_snapshot.provider, + "captured_at": device_snapshot.captured_at, + "num_qubits": device_snapshot.num_qubits, + "calibration_summary": device_snapshot.get_calibration_summary(), + } + + # Log circuits and get artifact refs + artifact_refs = serialize_and_log_circuits( + tracker=tracker, + circuits=circuits, + device_name=device_name, + ) + + # Create program snapshot + program_snapshot = create_program_snapshot( + circuits=circuits, + artifact_refs=artifact_refs, + circuit_hash=circuit_hash, + ) + + # Create execution snapshot + execution_snapshot = create_execution_snapshot( + shots=shots, + task_ids=task_ids, + submitted_at=submitted_at, + execution_index=execution_index, + options=options, + ) + + # Create ProducerInfo for UEC 1.0 + sdk_version = braket_version() + producer = ProducerInfo.create( + adapter="devqubit-braket", + adapter_version=get_adapter_version(), + sdk="braket", + sdk_version=sdk_version, + frontends=["braket-sdk"], + ) + + # Create pending result (UEC 1.0 requires result field) + pending_result = ResultSnapshot( + success=False, + status="failed", # Will be updated by finalize_envelope + items=[], + metadata={"state": "pending"}, + ) + + return ExecutionEnvelope( + envelope_id=uuid.uuid4().hex[:26], + created_at=utc_now_iso(), + producer=producer, + device=device_snapshot, + program=program_snapshot, + execution=execution_snapshot, + result=pending_result, + ) + + +def finalize_envelope( + tracker: Run, + envelope: ExecutionEnvelope, + result: Any, + device_name: str, + shots: int | None, + error: Exception | None = None, +) -> ExecutionEnvelope: + """ + Finalize envelope with result and log it. + + This function never raises exceptions - tracking should never crash + user experiments. Validation errors are logged but execution continues. + + Parameters + ---------- + tracker : Run + Tracker instance. + envelope : ExecutionEnvelope + Envelope to finalize. + result : Any + Braket result object (may be None on failure). + device_name : str + Device name. + shots : int or None + Number of shots. + error : Exception or None + Exception if execution failed. + + Returns + ------- + ExecutionEnvelope + Finalized envelope. + + Raises + ------ + ValueError + If envelope is None. + """ + from devqubit_braket.results import extract_counts_payload + + if envelope is None: + raise ValueError("Cannot finalize None envelope") + + # Log raw result and get ref + raw_result_ref: ArtifactRef | None = None + if result is not None: + try: + result_payload = to_jsonable(result) + except Exception: + result_payload = {"repr": repr(result)[:2000]} + + try: + raw_result_ref = tracker.log_json( + name="braket.result", + obj=result_payload, + role="results", + kind="result.braket.raw.json", + ) + except Exception as e: + logger.warning("Failed to log raw result: %s", e) + elif error: + try: + tracker.log_json( + name="braket.error", + obj={ + "error_type": type(error).__name__, + "error_message": str(error), + "timestamp": utc_now_iso(), + }, + role="results", + kind="result.braket.error.json", + ) + except Exception as e: + logger.warning("Failed to log error: %s", e) + + # Create result snapshot + result_snapshot = create_result_snapshot(result, raw_result_ref, shots, error) + + # Update execution snapshot with completion time + if envelope.execution: + envelope.execution.completed_at = utc_now_iso() + + # Add result to envelope + envelope.result = result_snapshot + + # Extract counts for separate logging + counts_payload = None + if result is not None: + try: + counts_payload = extract_counts_payload(result) + except Exception as e: + logger.debug("Failed to extract counts payload: %s", e) + + # Validate and log envelope + try: + tracker.log_envelope(envelope=envelope) + except Exception as e: + logger.warning("Failed to log envelope: %s", e) + + # Log normalized counts + if counts_payload is not None: + try: + tracker.log_json( + name="counts", + obj=counts_payload, + role="results", + kind="result.counts.json", + ) + except Exception as e: + logger.debug("Failed to log counts: %s", e) + + # Update tracker record (UEC 1.0 fields) + tracker.record["results"] = { + "completed_at": utc_now_iso(), + "backend_name": device_name, + "num_items": len(result_snapshot.items), + "status": result_snapshot.status, + "success": result_snapshot.success, + } + if error: + tracker.record["results"]["error"] = str(error) + tracker.record["results"]["error_type"] = type(error).__name__ + + logger.debug("Logged execution envelope for %s", device_name) + + return envelope + + +def log_submission_failure( + tracker: Run, + device_name: str, + error: Exception, + circuits: list[Any], + shots: int | None, + submitted_at: str, +) -> None: + """ + Log a task submission failure. + + Parameters + ---------- + tracker : Run + Tracker instance. + device_name : str + Device name. + error : Exception + The exception that occurred. + circuits : list + Circuits that were being submitted. + shots : int or None + Requested shots. + submitted_at : str + Submission timestamp. + """ + error_info = { + "type": "submission_failure", + "error_type": type(error).__name__, + "error_message": str(error), + "device_name": device_name, + "num_circuits": len(circuits), + "shots": shots, + "submitted_at": submitted_at, + "failed_at": utc_now_iso(), + } + + try: + tracker.log_json( + name="submission_failure", + obj=error_info, + role="error", + kind="devqubit.submission_failure.json", + ) + except Exception as e: + logger.warning("Failed to log submission failure: %s", e) diff --git a/packages/devqubit-braket/src/devqubit_braket/results.py b/packages/devqubit-braket/src/devqubit_braket/results.py index 2bf4528..d037bbd 100644 --- a/packages/devqubit-braket/src/devqubit_braket/results.py +++ b/packages/devqubit-braket/src/devqubit_braket/results.py @@ -4,16 +4,30 @@ """ Result processing for Braket adapter. -Provides functions for extracting measurement counts from -Braket task results, including Program Set results. +Provides functions for extracting measurement counts from Braket task results, +including Program Set results, with optional canonicalization to UEC format. + +Notes +----- +Braket uses big-endian bit ordering (qubit 0 = leftmost bit, cbit0_left). +UEC canonical format uses little-endian (qubit 0 = rightmost bit, cbit0_right). + +When extracting counts, use ``canonicalize=True`` to transform bitstrings +to the canonical format for cross-SDK comparison. """ from __future__ import annotations from typing import Any +from devqubit_braket.utils import canonicalize_counts + -def _to_counts_dict(x: Any) -> dict[str, int] | None: +def _to_counts_dict( + x: Any, + *, + canonicalize: bool = False, +) -> dict[str, int] | None: """ Convert a Counter/dict-like object into a {bitstring: count} dict. @@ -21,6 +35,8 @@ def _to_counts_dict(x: Any) -> dict[str, int] | None: ---------- x : Any Counter-like or dict-like object. + canonicalize : bool + If True, reverse bitstrings to canonical cbit0_right format. Returns ------- @@ -31,12 +47,19 @@ def _to_counts_dict(x: Any) -> dict[str, int] | None: return None try: d = dict(x) - return {str(k): int(v) for k, v in d.items()} + result = {str(k): int(v) for k, v in d.items()} + if canonicalize: + result = canonicalize_counts(result) + return result except Exception: return None -def extract_measurement_counts(result: Any) -> dict[str, int] | None: +def extract_measurement_counts( + result: Any, + *, + canonicalize: bool = False, +) -> dict[str, int] | None: """ Extract measurement counts from a single Braket result-like object. @@ -44,6 +67,9 @@ def extract_measurement_counts(result: Any) -> dict[str, int] | None: ---------- result : Any Braket result object (e.g., GateModelQuantumTaskResult). + canonicalize : bool + If True, reverse bitstrings to canonical cbit0_right format. + Default False (preserves Braket's native big-endian format). Returns ------- @@ -64,7 +90,7 @@ def extract_measurement_counts(result: Any) -> dict[str, int] | None: if hasattr(result, key): v = getattr(result, key) v = v() if callable(v) else v - out = _to_counts_dict(v) + out = _to_counts_dict(v, canonicalize=canonicalize) if out is not None: return out except Exception: @@ -73,7 +99,11 @@ def extract_measurement_counts(result: Any) -> dict[str, int] | None: return None -def extract_counts_payload(result: Any) -> dict[str, Any] | None: +def extract_counts_payload( + result: Any, + *, + canonicalize: bool = False, +) -> dict[str, Any] | None: """ Extract a devqubit-style counts payload from a Braket result. @@ -83,6 +113,9 @@ def extract_counts_payload(result: Any) -> dict[str, Any] | None: Braket result object. Supports: - GateModelQuantumTaskResult-like objects (single executable) - ProgramSetQuantumTaskResult-like objects (multiple executables) + canonicalize : bool + If True, reverse bitstrings to canonical cbit0_right format. + Default False (preserves Braket's native big-endian format). Returns ------- @@ -121,10 +154,12 @@ def extract_counts_payload(result: Any) -> dict[str, Any] | None: continue for executable_index, measured in enumerate(inner_entries): counts_obj = getattr(measured, "counts", None) - counts = _to_counts_dict(counts_obj) + counts = _to_counts_dict(counts_obj, canonicalize=canonicalize) if counts is None: # Fallback: see if measured itself has measurement_counts - counts = extract_measurement_counts(measured) + counts = extract_measurement_counts( + measured, canonicalize=canonicalize + ) if counts is None: continue @@ -146,7 +181,7 @@ def extract_counts_payload(result: Any) -> dict[str, Any] | None: # Single-result fallback try: - counts = extract_measurement_counts(result) + counts = extract_measurement_counts(result, canonicalize=canonicalize) except Exception: counts = None diff --git a/packages/devqubit-braket/src/devqubit_braket/snapshot.py b/packages/devqubit-braket/src/devqubit_braket/snapshot.py index d732667..cb5e4df 100644 --- a/packages/devqubit-braket/src/devqubit_braket/snapshot.py +++ b/packages/devqubit-braket/src/devqubit_braket/snapshot.py @@ -409,7 +409,7 @@ def create_device_snapshot( captured_at=captured_at, backend_name=backend_name, backend_type=backend_type, - provider="braket", + provider="aws_braket", backend_id=backend_id, num_qubits=num_qubits, connectivity=connectivity, diff --git a/packages/devqubit-braket/src/devqubit_braket/tracked.py b/packages/devqubit-braket/src/devqubit_braket/tracked.py new file mode 100644 index 0000000..53e54e5 --- /dev/null +++ b/packages/devqubit-braket/src/devqubit_braket/tracked.py @@ -0,0 +1,320 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Tracked task wrappers for Braket adapter. + +Provides TrackedTask and TrackedTaskBatch classes that wrap Braket +tasks to intercept result retrieval and finalize execution envelopes. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from devqubit_braket.envelope import finalize_envelope +from devqubit_braket.results import extract_counts_payload +from devqubit_braket.utils import extract_task_id + + +if TYPE_CHECKING: + from devqubit_engine.core.run import Run + from devqubit_engine.uec.envelope import ExecutionEnvelope + + +logger = logging.getLogger(__name__) + + +def combine_batch_results(results_list: list[Any]) -> dict[str, Any]: + """ + Combine batch results into a single structure for logging. + + Parameters + ---------- + results_list : list + List of individual result objects. + + Returns + ------- + dict + Combined result structure. + """ + experiments: list[dict[str, Any]] = [] + + for i, result in enumerate(results_list): + if result is None: + experiments.append({"index": i, "status": "failed", "counts": {}}) + continue + + counts_payload = extract_counts_payload(result) + if counts_payload and counts_payload.get("experiments"): + for exp in counts_payload["experiments"]: + exp_copy = dict(exp) + exp_copy["batch_index"] = i + experiments.append(exp_copy) + else: + experiments.append({"index": i, "batch_index": i, "counts": {}}) + + return {"experiments": experiments, "batch_size": len(results_list)} + + +@dataclass +class TrackedTask: + """ + Wrapper for Braket task that tracks result retrieval. + + Intercepts `result()` calls to finalize the execution envelope + with result data. Handles exceptions gracefully with failure logging. + + Parameters + ---------- + task : Any + Original Braket task instance. + tracker : Run + Tracker instance for logging. + device_name : str + Name of the device that created this task. + envelope : ExecutionEnvelope or None + Envelope to finalize with results. + shots : int or None + Number of shots for this execution. + should_log_results : bool + Whether to log results for this task. + """ + + task: Any + tracker: Run + device_name: str + envelope: ExecutionEnvelope | None = None + shots: int | None = None + should_log_results: bool = True + _result_logged: bool = field(default=False, init=False, repr=False) + + def result(self, *args: Any, **kwargs: Any) -> Any: + """ + Retrieve task result and finalize envelope. + + Handles exceptions gracefully, logging failure information + before re-raising the ORIGINAL exception (P0 fix: preserve exception type). + + Parameters + ---------- + *args : Any + Positional arguments passed to underlying result(). + **kwargs : Any + Keyword arguments passed to underlying result(). + + Returns + ------- + Any + Braket result object. + + Raises + ------ + Exception + Re-raises original exception from underlying result() after logging. + """ + result = None + + try: + result = self.task.result(*args, **kwargs) + except Exception as e: + logger.warning("Task result() failed on %s: %s", self.device_name, e) + + # Log failure envelope before re-raising original exception + if self.should_log_results and self.envelope and not self._result_logged: + self._result_logged = True + try: + finalize_envelope( + tracker=self.tracker, + envelope=self.envelope, + result=None, + device_name=self.device_name, + shots=self.shots, + error=e, + ) + except Exception as log_err: + logger.warning( + "Failed to log error envelope for %s: %s", + self.device_name, + log_err, + ) + + raise + + # Log successful result + if self.should_log_results and self.envelope and not self._result_logged: + self._result_logged = True + try: + finalize_envelope( + tracker=self.tracker, + envelope=self.envelope, + result=result, + device_name=self.device_name, + shots=self.shots, + ) + logger.debug("Finalized envelope for task on %s", self.device_name) + except Exception as log_err: + logger.warning( + "Failed to finalize envelope for %s: %s", + self.device_name, + log_err, + ) + # Record error in tracker for visibility + self.tracker.record.setdefault("warnings", []).append( + { + "type": "result_logging_failed", + "message": str(log_err), + "device_name": self.device_name, + } + ) + + return result + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to wrapped task.""" + return getattr(self.task, name) + + def __repr__(self) -> str: + """Return string representation.""" + task_id = extract_task_id(self.task) or "unknown" + return f"TrackedTask(device={self.device_name!r}, task_id={task_id!r})" + + +@dataclass +class TrackedTaskBatch: + """ + Wrapper for Braket task batch that tracks result retrieval. + + Wraps AwsQuantumTaskBatch to intercept `results()` calls and log + all results with proper handling of partial failures. + + Parameters + ---------- + batch : Any + Original Braket task batch instance. + tracker : Run + Tracker instance for logging. + device_name : str + Name of the device that created this batch. + envelope : ExecutionEnvelope or None + Envelope to finalize with results. + shots : int or None + Number of shots for this execution. + should_log_results : bool + Whether to log results for this batch. + """ + + batch: Any + tracker: Run + device_name: str + envelope: ExecutionEnvelope | None = None + shots: int | None = None + should_log_results: bool = True + _results_logged: bool = field(default=False, init=False, repr=False) + + def results(self, *args: Any, **kwargs: Any) -> list[Any]: + """ + Retrieve batch results and finalize envelope. + + Handles partial failures (None results for failed tasks). + + Parameters + ---------- + *args : Any + Positional arguments passed to underlying results(). + **kwargs : Any + Keyword arguments passed to underlying results(). + + Returns + ------- + list + List of Braket result objects (may contain None for failed tasks). + + Raises + ------ + Exception + Re-raises original exception from underlying results() after logging. + """ + results_list: list[Any] = [] + partial_error: Exception | None = None + + try: + results_list = self.batch.results(*args, **kwargs) + except Exception as e: + logger.warning("Batch results() failed on %s: %s", self.device_name, e) + + if self.should_log_results and self.envelope and not self._results_logged: + self._results_logged = True + try: + finalize_envelope( + self.tracker, + self.envelope, + None, + self.device_name, + self.shots, + error=e, + ) + except Exception as log_err: + logger.warning( + "Failed to log error envelope for batch %s: %s", + self.device_name, + log_err, + ) + + raise + + # Check for partial failures (None in results) + failed_count = sum(1 for r in results_list if r is None) + if failed_count > 0: + logger.warning( + "Batch on %s: %d/%d tasks failed", + self.device_name, + failed_count, + len(results_list), + ) + partial_error = RuntimeError( + f"Partial failure: {failed_count}/{len(results_list)} tasks failed" + ) + + # Aggregate successful results for logging + if self.should_log_results and self.envelope and not self._results_logged: + self._results_logged = True + try: + combined_result = combine_batch_results(results_list) + + finalize_envelope( + self.tracker, + self.envelope, + combined_result, + self.device_name, + self.shots, + error=partial_error, + ) + logger.debug("Finalized envelope for batch on %s", self.device_name) + except Exception as log_err: + logger.warning( + "Failed to finalize envelope for batch %s: %s", + self.device_name, + log_err, + ) + # Record error in tracker for visibility + self.tracker.record.setdefault("warnings", []).append( + { + "type": "batch_result_logging_failed", + "message": str(log_err), + "device_name": self.device_name, + } + ) + + return results_list + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to wrapped batch.""" + return getattr(self.batch, name) + + def __repr__(self) -> str: + """Return string representation.""" + return f"TrackedTaskBatch(device={self.device_name!r})" diff --git a/packages/devqubit-braket/src/devqubit_braket/utils.py b/packages/devqubit-braket/src/devqubit_braket/utils.py index 331ca57..0904746 100644 --- a/packages/devqubit-braket/src/devqubit_braket/utils.py +++ b/packages/devqubit-braket/src/devqubit_braket/utils.py @@ -4,8 +4,8 @@ """ Utility functions for Braket adapter. -Provides version utilities and common helpers used across -the adapter components. +Provides version utilities, bitstring canonicalization, and common helpers +used across the adapter components. """ from __future__ import annotations @@ -13,6 +13,11 @@ from typing import Any +# ============================================================================= +# Version utilities +# ============================================================================= + + def braket_version() -> str: """ Get the installed Amazon Braket SDK version. @@ -31,6 +36,93 @@ def braket_version() -> str: return "unknown" +def get_adapter_version() -> str: + """ + Get the devqubit-braket adapter version. + + Returns + ------- + str + Adapter version string, or "unknown" if not installed. + """ + try: + from importlib.metadata import version + + return version("devqubit-braket") + except Exception: + return "unknown" + + +# ============================================================================= +# Bitstring canonicalization +# ============================================================================= + + +def reverse_bitstring(bitstring: str) -> str: + """ + Reverse a bitstring (convert between big-endian and little-endian). + + Braket uses big-endian (qubit 0 = leftmost bit, cbit0_left). + UEC canonical format is little-endian (qubit 0 = rightmost bit, cbit0_right). + + Parameters + ---------- + bitstring : str + Input bitstring (e.g., "011"). + + Returns + ------- + str + Reversed bitstring (e.g., "110"). + + Examples + -------- + >>> reverse_bitstring("011") + '110' + >>> reverse_bitstring("00") + '00' + """ + return bitstring[::-1] + + +def canonicalize_counts( + counts: dict[str, int], + *, + reverse: bool = True, +) -> dict[str, int]: + """ + Transform measurement counts to canonical UEC format. + + Braket returns counts with big-endian bitstrings (cbit0_left). + UEC canonical format uses little-endian (cbit0_right, like Qiskit). + + Parameters + ---------- + counts : dict + Measurement counts from Braket {bitstring: count}. + reverse : bool, optional + Whether to reverse bitstrings to canonical format. Default True. + + Returns + ------- + dict + Counts with canonicalized bitstrings. + + Examples + -------- + >>> canonicalize_counts({"01": 50, "10": 50}) + {'10': 50, '01': 50} + """ + if not reverse: + return counts + return {reverse_bitstring(k): v for k, v in counts.items()} + + +# ============================================================================= +# Device utilities +# ============================================================================= + + def get_backend_name(device: Any) -> str: """ Extract device name from a Braket device. @@ -87,6 +179,11 @@ def extract_task_id(task: Any) -> str | None: return None +# ============================================================================= +# Conversion utilities +# ============================================================================= + + def to_float(x: Any) -> float | None: """ Convert to float, returning None on failure. diff --git a/packages/devqubit-braket/tests/test_braket_adapter.py b/packages/devqubit-braket/tests/test_braket_adapter.py index c456105..98b16b0 100644 --- a/packages/devqubit-braket/tests/test_braket_adapter.py +++ b/packages/devqubit-braket/tests/test_braket_adapter.py @@ -85,8 +85,8 @@ def test_logged_execution_creates_envelope_and_expected_artifacts( assert loaded.status == "FINISHED" # Tags/params are part of the adapter contract (only set on logged executions) - assert loaded.record["data"]["tags"]["provider"] == "braket" - assert loaded.record["data"]["tags"]["adapter"] == "braket" + assert loaded.record["data"]["tags"]["provider"] == "aws_braket" + assert loaded.record["data"]["tags"]["adapter"] == "devqubit-braket" assert loaded.record["data"]["params"]["shots"] == shots assert loaded.record["data"]["params"]["num_circuits"] == 1 @@ -98,13 +98,14 @@ def test_logged_execution_creates_envelope_and_expected_artifacts( assert "result.counts.json" in kinds assert "devqubit.envelope.json" in kinds - # Validate envelope structure + key fields (UEC contract) + # Validate envelope structure + key fields (UEC 1.0 contract) envelope_art = _artifacts_of_kind(loaded, "devqubit.envelope.json")[0] envelope = _read_artifact_json(store, envelope_art) - assert envelope["schema"] == "devqubit.envelope/0.1" - assert envelope["adapter"] == "braket" - assert envelope["device"]["provider"] == "braket" + assert envelope["schema"] == "devqubit.envelope/1.0" + assert envelope["producer"]["adapter"] == "devqubit-braket" + assert envelope["producer"]["sdk"] == "braket" + assert envelope["device"]["provider"] == "aws_braket" assert envelope["execution"]["shots"] == shots assert envelope["execution"]["sdk"] == "braket" assert envelope["execution"]["transpilation"]["mode"] == "managed" @@ -118,13 +119,13 @@ def test_logged_execution_creates_envelope_and_expected_artifacts( assert logical[0]["role"] == "logical" assert logical[0]["ref"]["digest"] in jaqcd_digests - # Result snapshot should include normalized counts summing to shots - assert envelope["result"]["result_type"] == "counts" + # Result snapshot should include items with counts summing to shots (UEC 1.0) + assert envelope["result"]["status"] == "completed" assert envelope["result"]["success"] is True - assert envelope["result"]["num_experiments"] == 1 - c0 = envelope["result"]["counts"][0] - assert c0["circuit_index"] == 0 - assert sum(c0["counts"].values()) == shots + assert len(envelope["result"]["items"]) == 1 + item0 = envelope["result"]["items"][0] + assert item0["item_index"] == 0 + assert sum(item0["counts"]["counts"].values()) == shots # Counts artifact should be query-friendly and consistent counts_art = _artifacts_of_kind(loaded, "result.counts.json")[0] diff --git a/packages/devqubit-braket/tests/test_braket_snapshot.py b/packages/devqubit-braket/tests/test_braket_snapshot.py index 3fdbce0..6f7cbd8 100644 --- a/packages/devqubit-braket/tests/test_braket_snapshot.py +++ b/packages/devqubit-braket/tests/test_braket_snapshot.py @@ -13,7 +13,7 @@ def test_local_simulator_snapshot(self, local_simulator): """Creates snapshot from real LocalSimulator.""" snap = create_device_snapshot(local_simulator) - assert snap.provider == "braket" + assert snap.provider == "aws_braket" assert snap.backend_name is not None assert snap.backend_type == "simulator" # Schema-valid value assert snap.captured_at is not None @@ -30,8 +30,8 @@ def test_snapshot_to_dict_format(self, local_simulator): snap = create_device_snapshot(local_simulator) d = snap.to_dict() - assert d["schema"] == "devqubit.device_snapshot/0.1" - assert d["provider"] == "braket" + assert d["schema"] == "devqubit.device_snapshot/1.0" + assert d["provider"] == "aws_braket" assert d["backend_type"] == "simulator" assert "sdk_versions" in d assert isinstance(d["sdk_versions"], dict) @@ -274,7 +274,7 @@ def name(self): snap = create_device_snapshot(NoPropsDevice()) - assert snap.provider == "braket" + assert snap.provider == "aws_braket" assert snap.backend_type == "simulator" # Default for non-AWS assert snap.num_qubits is None assert snap.connectivity is None @@ -296,7 +296,7 @@ def properties(self): snap = create_device_snapshot(BrokenDevice()) - assert snap.provider == "braket" + assert snap.provider == "aws_braket" assert snap.captured_at is not None # Should not crash, just return None for unavailable data assert snap.num_qubits is None diff --git a/packages/devqubit-cirq/src/devqubit_cirq/adapter.py b/packages/devqubit-cirq/src/devqubit_cirq/adapter.py index 1fe9526..da458c0 100644 --- a/packages/devqubit-cirq/src/devqubit_cirq/adapter.py +++ b/packages/devqubit-cirq/src/devqubit_cirq/adapter.py @@ -29,6 +29,7 @@ import hashlib import logging +import uuid from dataclasses import dataclass, field from typing import Any @@ -39,22 +40,29 @@ is_cirq_circuit, ) from devqubit_cirq.snapshot import create_device_snapshot -from devqubit_cirq.utils import cirq_version, get_backend_name +from devqubit_cirq.utils import cirq_version, get_adapter_version, get_backend_name from devqubit_engine.circuit.models import CircuitFormat from devqubit_engine.core.run import Run from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.envelope import ExecutionEnvelope from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.producer import ProducerInfo from devqubit_engine.uec.program import ( ProgramArtifact, ProgramSnapshot, TranspilationInfo, ) -from devqubit_engine.uec.result import NormalizedCounts, ResultSnapshot + +# UEC 1.0 imports +from devqubit_engine.uec.result import ( + CountsFormat, + ResultError, + ResultItem, + ResultSnapshot, +) from devqubit_engine.uec.types import ( ArtifactRef, ProgramRole, - ResultType, TranspilationMode, ) from devqubit_engine.utils.serialization import to_jsonable @@ -63,6 +71,7 @@ logger = logging.getLogger(__name__) + # Module-level serializer instance _serializer = CirqCircuitSerializer() @@ -297,63 +306,104 @@ def _create_result_snapshot( raw_result_ref: ArtifactRef | None, repetitions: int | None, is_sweep: bool = False, + error_info: dict[str, Any] | None = None, ) -> ResultSnapshot: """ - Create a ResultSnapshot from Cirq result(s). + Create a ResultSnapshot from Cirq result(s) using UEC 1.0 API. Parameters ---------- result : Any - Cirq result object or list of results. + Cirq result object or list of results (None if execution failed). raw_result_ref : ArtifactRef or None Reference to raw result artifact. repetitions : int or None Number of repetitions used. is_sweep : bool Whether this is from a parameter sweep. + error_info : dict, optional + Error information if execution failed. + Contains "type" and "message" keys. Returns ------- ResultSnapshot - Result snapshot with normalized counts. + Result snapshot with normalized counts (UEC 1.0 format). """ - if result is None: + # Handle failure case (error_info provided) + if error_info is not None: + error = ResultError( + type=error_info.get("type", "UnknownError"), + message=error_info.get("message", "Unknown error"), + ) + metadata: dict[str, Any] = {"sweep": is_sweep} if is_sweep else {} return ResultSnapshot( - result_type=ResultType.COUNTS, + success=False, + status="failed", + items=[], + error=error, raw_result_ref=raw_result_ref, - counts=[], - num_experiments=0, + metadata=metadata, + ) + + # Handle None result (without explicit error) + if result is None: + return ResultSnapshot( success=False, - metadata=( - {"sweep": is_sweep, "error": "Result is None"} - if is_sweep - else {"error": "Result is None"} - ), + status="failed", + items=[], + error=ResultError(type="NullResult", message="Result is None"), + raw_result_ref=raw_result_ref, + metadata={"sweep": is_sweep} if is_sweep else {}, ) + # Process successful result try: counts_payload = normalize_counts_payload(result) except Exception as e: logger.debug("Failed to normalize counts payload: %s", e) - counts_payload = {"experiments": []} + counts_payload = {"experiments": [], "format": {}} + + # Build UEC 1.0 items list + items: list[ResultItem] = [] + + # Get format from payload + format_dict = counts_payload.get("format", {}) + counts_format = CountsFormat( + source_sdk=format_dict.get("source_sdk", "cirq"), + source_key_format=format_dict.get( + "source_key_format", "measurement_key_concatenated" + ), + bit_order=format_dict.get("bit_order", "cbit0_left"), + transformed=format_dict.get("transformed", False), + ) - normalized_counts: list[NormalizedCounts] = [ - NormalizedCounts( - circuit_index=exp.get("index", 0), - counts=exp.get("counts", {}), - shots=repetitions, - name=exp.get("name"), + for exp in counts_payload.get("experiments", []): + circuit_index = exp.get("index", 0) + counts = exp.get("counts", {}) + + items.append( + ResultItem( + item_index=circuit_index, + success=True, + counts={ + "counts": counts, + "shots": repetitions, + "format": counts_format.to_dict(), + }, + ) ) - for exp in counts_payload.get("experiments", []) - ] + + metadata = {"sweep": is_sweep} if is_sweep else {} + metadata["num_experiments"] = len(items) return ResultSnapshot( - result_type=ResultType.COUNTS, + success=len(items) > 0, + status="completed" if len(items) > 0 else "failed", + items=items, + error=None, raw_result_ref=raw_result_ref, - counts=normalized_counts, - num_experiments=len(normalized_counts), - success=len(normalized_counts) > 0, - metadata={"sweep": is_sweep} if is_sweep else {}, + metadata=metadata, ) @@ -410,16 +460,16 @@ def _create_and_log_envelope( captured_at=utc_now_iso(), backend_name=simulator_name, backend_type="simulator", - provider="cirq", + provider="local", # Physical provider, not SDK sdk_versions={"cirq": cirq_version()}, ) # Update tracker record with device snapshot tracker.record["device_snapshot"] = { - "sdk": "cirq", + "sdk": "cirq", # SDK frontend (always cirq for this adapter) "backend_name": simulator_name, "backend_type": device_snapshot.backend_type, - "provider": device_snapshot.provider, + "provider": device_snapshot.provider, # Physical provider from snapshot "captured_at": device_snapshot.captured_at, "num_qubits": device_snapshot.num_qubits, } @@ -427,16 +477,35 @@ def _create_and_log_envelope( # Serialize and log circuits artifact_refs = _serialize_and_log_circuits(tracker, circuits, simulator_name) + # UEC 1.0: Create ProducerInfo + sdk_version = cirq_version() + producer = ProducerInfo.create( + adapter="devqubit-cirq", + adapter_version=get_adapter_version(), + sdk="cirq", + sdk_version=sdk_version, + frontends=["cirq"], + ) + + # Create pending result (will be updated when execution completes) + # UEC 1.0 requires status to be one of: completed, failed, cancelled, partial + pending_result = ResultSnapshot( + success=False, + status="failed", # Valid status - will be updated by _finalize_envelope_with_result + items=[], + metadata={"state": "pending"}, + ) + return ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="cirq", + envelope_id=uuid.uuid4().hex[:26], created_at=utc_now_iso(), + producer=producer, + result=pending_result, # Must be valid ResultSnapshot, not None device=device_snapshot, program=_create_program_snapshot(circuits, artifact_refs, circuit_hash), execution=_create_execution_snapshot( repetitions, submitted_at, is_sweep, params, options ), - result=None, # Will be filled when execution completes ) @@ -447,6 +516,7 @@ def _finalize_envelope_with_result( simulator_name: str, repetitions: int | None, is_sweep: bool = False, + error_info: dict[str, Any] | None = None, ) -> ExecutionEnvelope: """ Finalize envelope with result and log it. @@ -461,13 +531,16 @@ def _finalize_envelope_with_result( envelope : ExecutionEnvelope Envelope to finalize. result : Any - Cirq result object or list of results. + Cirq result object or list of results (None if execution failed). simulator_name : str Simulator name. repetitions : int or None Number of repetitions. is_sweep : bool Whether this is from a parameter sweep. + error_info : dict, optional + Error information if execution failed. + Contains "type" and "message" keys. Returns ------- @@ -482,29 +555,31 @@ def _finalize_envelope_with_result( if envelope is None: raise ValueError("Cannot finalize None envelope") - # Log raw result + # Log raw result (if we have one) raw_result_ref = None - try: + if result is not None: try: - result_payload = to_jsonable(result) - except Exception: - result_payload = {"repr": repr(result)[:2000]} - - raw_result_ref = tracker.log_json( - name="cirq.result", - obj=result_payload, - role="results", - kind="result.cirq.raw.json", - ) - except Exception as e: - logger.warning("Failed to log raw result: %s", e) + try: + result_payload = to_jsonable(result) + except Exception: + result_payload = {"repr": repr(result)[:2000]} + + raw_result_ref = tracker.log_json( + name="cirq.result", + obj=result_payload, + role="results", + kind="result.cirq.raw.json", + ) + except Exception as e: + logger.warning("Failed to log raw result: %s", e) - # Update envelope + # Update envelope with result snapshot (handles both success and failure) envelope.result = _create_result_snapshot( result=result, raw_result_ref=raw_result_ref, repetitions=repetitions, is_sweep=is_sweep, + error_info=error_info, ) if envelope.execution: @@ -621,6 +696,7 @@ def _track_execution( is_batch: bool = False, params: Any = None, extra_options: dict[str, Any] | None = None, + error_info: dict[str, Any] | None = None, ) -> None: """ Common execution tracking logic for run, run_sweep, and run_batch. @@ -630,7 +706,7 @@ def _track_execution( circuit_list : list List of executed circuits. result : Any - Execution result. + Execution result (None if execution failed). repetitions : int Number of repetitions. submitted_at : str @@ -643,6 +719,9 @@ def _track_execution( Parameter sweep or resolver. extra_options : dict, optional Additional options to include. + error_info : dict, optional + Error information if execution failed. + Contains "type" and "message" keys. """ simulator_name = get_backend_name(self.simulator) @@ -686,6 +765,7 @@ def _track_execution( simulator_name=simulator_name, repetitions=repetitions, is_sweep=is_sweep, + error_info=error_info, ) except Exception as e: logger.warning( @@ -705,10 +785,20 @@ def _track_execution( self._logged_circuit_hashes.add(circuit_hash) self._logged_execution_count += 1 + # Get physical provider from device snapshot (if available) + # The device snapshot is created inside the envelope + physical_provider = "local" # Default for Cirq simulators + try: + device_snapshot = self.tracker.record.get("device_snapshot", {}) + physical_provider = device_snapshot.get("provider", "local") + except Exception: + pass + # Set tracker tags and params self.tracker.set_tag("backend_name", simulator_name) - self.tracker.set_tag("provider", "cirq") - self.tracker.set_tag("adapter", "cirq") + self.tracker.set_tag("provider", physical_provider) # Physical provider + self.tracker.set_tag("sdk", "cirq") # SDK frontend + self.tracker.set_tag("adapter", "devqubit-cirq") self.tracker.log_param("repetitions", repetitions) self.tracker.log_param("num_circuits", len(circuit_list)) @@ -721,7 +811,8 @@ def _track_execution( self.tracker.record["backend"] = { "name": simulator_name, "type": self.simulator.__class__.__name__, - "provider": "cirq", + "provider": physical_provider, # Physical provider + "sdk": "cirq", # SDK frontend } self.tracker.record["execute"] = { @@ -771,25 +862,59 @@ def run( ------- cirq.Result Cirq Result object containing measurement outcomes. + + Raises + ------ + Exception + Re-raises any exception from the simulator after logging + a failure envelope. """ circuit_list, _ = _materialize_circuits(program) submitted_at = utc_now_iso() - result = self.simulator.run(program, *args, repetitions=repetitions, **kwargs) - extra_options: dict[str, Any] = {} if args: extra_options["args"] = to_jsonable(list(args)) if kwargs: extra_options["kwargs"] = to_jsonable(kwargs) - self._track_execution( - circuit_list, - result, - repetitions, - submitted_at, - extra_options=extra_options if extra_options else None, - ) + # Capture exception and log failure envelope before re-raising + result: Any = None + original_exception: BaseException | None = None + execution_succeeded = False + + try: + result = self.simulator.run( + program, *args, repetitions=repetitions, **kwargs + ) + execution_succeeded = True + except Exception as e: + original_exception = e + # Log failure envelope + self._track_execution( + circuit_list, + None, # No result + repetitions, + submitted_at, + extra_options=extra_options if extra_options else None, + error_info={ + "type": type(e).__name__, + "message": str(e), + }, + ) + + if execution_succeeded: + self._track_execution( + circuit_list, + result, + repetitions, + submitted_at, + extra_options=extra_options if extra_options else None, + ) + + # Re-raise original exception preserving type and traceback + if original_exception is not None: + raise original_exception return result @@ -821,29 +946,63 @@ def run_sweep( ------- list of cirq.Result List of Result objects, one per parameter set. + + Raises + ------ + Exception + Re-raises any exception from the simulator after logging + a failure envelope. """ circuit_list, _ = _materialize_circuits(program) submitted_at = utc_now_iso() - results = self.simulator.run_sweep( - program, params, *args, repetitions=repetitions, **kwargs - ) - extra_options: dict[str, Any] = {} if args: extra_options["args"] = to_jsonable(list(args)) if kwargs: extra_options["kwargs"] = to_jsonable(kwargs) - self._track_execution( - circuit_list, - results, - repetitions, - submitted_at, - is_sweep=True, - params=params, - extra_options=extra_options if extra_options else None, - ) + # Capture exception and log failure envelope before re-raising + results: Any = None + original_exception: BaseException | None = None + execution_succeeded = False + + try: + results = self.simulator.run_sweep( + program, params, *args, repetitions=repetitions, **kwargs + ) + execution_succeeded = True + except Exception as e: + original_exception = e + # Log failure envelope + self._track_execution( + circuit_list, + None, # No result + repetitions, + submitted_at, + is_sweep=True, + params=params, + extra_options=extra_options if extra_options else None, + error_info={ + "type": type(e).__name__, + "message": str(e), + }, + ) + + if execution_succeeded: + self._track_execution( + circuit_list, + results, + repetitions, + submitted_at, + is_sweep=True, + params=params, + extra_options=extra_options if extra_options else None, + ) + + # Re-raise original exception preserving type and traceback + if original_exception is not None: + raise original_exception return results @@ -881,14 +1040,16 @@ def run_batch( list of list of cirq.Result Nested list where results[i][j] is the result for circuit i with parameter set j. + + Raises + ------ + Exception + Re-raises any exception from the simulator after logging + a failure envelope. """ circuit_list, _ = _materialize_circuits(programs) submitted_at = utc_now_iso() - results = self.simulator.run_batch( - programs, params_list, *args, repetitions=repetitions, **kwargs - ) - # Determine effective repetitions for logging if isinstance(repetitions, (list, tuple)): total_reps = repetitions[0] if repetitions else 1 @@ -905,16 +1066,49 @@ def run_batch( if kwargs: extra_options["kwargs"] = to_jsonable(kwargs) - self._track_execution( - circuit_list, - results, - total_reps, - submitted_at, - is_sweep=True, - is_batch=True, - params=params_list, - extra_options=extra_options if extra_options else None, - ) + # Capture exception and log failure envelope before re-raising + results: Any = None + original_exception: BaseException | None = None + execution_succeeded = False + + try: + results = self.simulator.run_batch( + programs, params_list, *args, repetitions=repetitions, **kwargs + ) + execution_succeeded = True + except Exception as e: + original_exception = e + # Log failure envelope + self._track_execution( + circuit_list, + None, # No result + total_reps, + submitted_at, + is_sweep=True, + is_batch=True, + params=params_list, + extra_options=extra_options if extra_options else None, + error_info={ + "type": type(e).__name__, + "message": str(e), + }, + ) + + if execution_succeeded: + self._track_execution( + circuit_list, + results, + total_reps, + submitted_at, + is_sweep=True, + is_batch=True, + params=params_list, + extra_options=extra_options if extra_options else None, + ) + + # Re-raise original exception preserving type and traceback + if original_exception is not None: + raise original_exception return results @@ -998,12 +1192,21 @@ def describe_executor(self, simulator: Any) -> dict[str, Any]: Returns ------- dict - Simulator description with name, type, and provider. + Simulator description with name, type, provider, and SDK. """ + # Import provider detection from snapshot module + from devqubit_cirq.snapshot import _detect_execution_provider + + try: + physical_provider = _detect_execution_provider(simulator) + except Exception: + physical_provider = "local" + return { "name": get_backend_name(simulator), "type": simulator.__class__.__name__, - "provider": "cirq", + "provider": physical_provider, # Physical provider + "sdk": "cirq", # SDK frontend } def wrap_executor( diff --git a/packages/devqubit-cirq/src/devqubit_cirq/results.py b/packages/devqubit-cirq/src/devqubit_cirq/results.py index 1d35274..0a705f5 100644 --- a/packages/devqubit-cirq/src/devqubit_cirq/results.py +++ b/packages/devqubit-cirq/src/devqubit_cirq/results.py @@ -14,6 +14,34 @@ import numpy as np +# Use CountsFormat from UEC for format metadata +from devqubit_engine.uec.result import CountsFormat + + +def _get_cirq_counts_format() -> dict[str, Any]: + """ + Get CountsFormat metadata for Cirq results. + + Cirq measurement arrays are converted to bitstring counts by: + 1. Sorting measurement keys alphabetically + 2. Concatenating bits from each key in sorted order + 3. Converting each row to a bitstring (left-to-right = first-to-last bit) + + Cirq uses big-endian bit order (first qubit = leftmost bit), + which corresponds to cbit0_left in UEC canonical terminology. + + Returns + ------- + dict + CountsFormat as dictionary for JSON serialization. + """ + return CountsFormat( + source_sdk="cirq", + source_key_format="measurement_key_concatenated", + bit_order="cbit0_left", # Cirq big-endian = cbit0_left in UEC terminology + transformed=False, # Not transformed to canonical cbit0_right + ).to_dict() + def get_result_measurements(result: Any) -> dict[str, Any]: """ @@ -254,9 +282,11 @@ def normalize_counts_payload(results: Any) -> dict[str, Any]: >>> payload = normalize_counts_payload(result) >>> payload["experiments"][0]["counts"] {'00': 48, '11': 52} + >>> payload["format"]["source_sdk"] + 'cirq' """ if results is None: - return {"experiments": []} + return {"experiments": [], "format": _get_cirq_counts_format()} experiments: list[dict[str, Any]] = [] @@ -266,7 +296,10 @@ def normalize_counts_payload(results: Any) -> dict[str, Any]: experiments.append(_process_result(results, 0)) except Exception: pass - return {"experiments": experiments} + return { + "experiments": experiments, + "format": _get_cirq_counts_format(), + } # List of results if isinstance(results, (list, tuple)) and results: @@ -284,7 +317,10 @@ def normalize_counts_payload(results: Any) -> dict[str, Any]: except Exception: pass idx += 1 - return {"experiments": experiments} + return { + "experiments": experiments, + "format": _get_cirq_counts_format(), + } # Flat list of results (run_sweep) for i, r in enumerate(results): @@ -293,4 +329,7 @@ def normalize_counts_payload(results: Any) -> dict[str, Any]: except Exception: pass - return {"experiments": experiments} + return { + "experiments": experiments, + "format": _get_cirq_counts_format(), + } diff --git a/packages/devqubit-cirq/src/devqubit_cirq/snapshot.py b/packages/devqubit-cirq/src/devqubit_cirq/snapshot.py index 5792b1c..11ab6bb 100644 --- a/packages/devqubit-cirq/src/devqubit_cirq/snapshot.py +++ b/packages/devqubit-cirq/src/devqubit_cirq/snapshot.py @@ -24,6 +24,58 @@ logger = logging.getLogger(__name__) +def _detect_execution_provider(executor: Any) -> str: + """ + Detect the physical execution provider for a Cirq executor. + + This distinguishes between the SDK (always "cirq") and the physical + execution platform where circuits actually run. + + Parameters + ---------- + executor : Any + Cirq sampler or simulator. + + Returns + ------- + str + Physical provider identifier: + - "local" - Local simulators (Simulator, DensityMatrixSimulator, etc.) + - "google_quantum" - Google Quantum Engine / Quantum AI + - "ionq" - IonQ hardware via cirq_ionq + - "aqt" - Alpine Quantum Technologies + - "pasqal" - Pasqal neutral atoms + - "rigetti" - Rigetti via cirq_rigetti + """ + class_name = executor.__class__.__name__.lower() + module = getattr(executor, "__module__", "").lower() + + # Google Quantum Engine / Quantum AI + if "engine" in module or "google" in module: + return "google_quantum" + if "processor" in class_name and "google" in module: + return "google_quantum" + + # IonQ + if "ionq" in module or "ionq" in class_name: + return "ionq" + + # AQT (Alpine Quantum Technologies) + if "aqt" in module or "aqt" in class_name: + return "aqt" + + # Pasqal + if "pasqal" in module or "pasqal" in class_name: + return "pasqal" + + # Rigetti + if "rigetti" in module or "rigetti" in class_name: + return "rigetti" + + # Default: local simulator + return "local" + + def _resolve_backend_type(executor: Any) -> str: """ Resolve backend_type to a schema-valid value. @@ -359,7 +411,7 @@ def create_device_snapshot( >>> snapshot.backend_name 'Simulator' >>> snapshot.provider - 'cirq' + 'local' """ if executor is None: raise ValueError("Cannot create device snapshot from None executor") @@ -377,6 +429,13 @@ def create_device_snapshot( logger.debug("Failed to resolve backend type: %s", e) backend_type = "simulator" + # Detect physical provider (not SDK frontend) + try: + physical_provider = _detect_execution_provider(executor) + except Exception as e: + logger.debug("Failed to detect execution provider: %s", e) + physical_provider = "local" + try: num_qubits = _extract_num_qubits(executor) except Exception as e: @@ -405,6 +464,8 @@ def create_device_snapshot( if tracker is not None: try: raw_properties = _build_raw_properties(executor) + # Include execution provider in raw properties + raw_properties["execution_provider"] = physical_provider raw_properties_ref = tracker.log_json( name="device_raw_properties", obj=raw_properties, @@ -419,7 +480,7 @@ def create_device_snapshot( captured_at=utc_now_iso(), backend_name=backend_name, backend_type=backend_type, - provider="cirq", + provider=physical_provider, # Physical provider, not "cirq" num_qubits=num_qubits, connectivity=connectivity, native_gates=native_gates, diff --git a/packages/devqubit-cirq/src/devqubit_cirq/utils.py b/packages/devqubit-cirq/src/devqubit_cirq/utils.py index dfa4a21..a8c35a4 100644 --- a/packages/devqubit-cirq/src/devqubit_cirq/utils.py +++ b/packages/devqubit-cirq/src/devqubit_cirq/utils.py @@ -31,6 +31,16 @@ def cirq_version() -> str: return "unknown" +def get_adapter_version() -> str: + """Get adapter version dynamically from package metadata.""" + try: + from importlib.metadata import version + + return version("devqubit-cirq") + except Exception: + return "unknown" + + def get_backend_name(executor: Any) -> str: """ Extract backend name from a Cirq sampler or simulator. diff --git a/packages/devqubit-cirq/tests/test_cirq_adapter.py b/packages/devqubit-cirq/tests/test_cirq_adapter.py index 562c81c..f3dbca8 100644 --- a/packages/devqubit-cirq/tests/test_cirq_adapter.py +++ b/packages/devqubit-cirq/tests/test_cirq_adapter.py @@ -71,17 +71,22 @@ def test_emits_consistent_envelope_and_counts( loaded = registry.load(run.run_id) assert loaded.status == "FINISHED" - assert loaded.record["backend"]["provider"] == "cirq" + # P1 FIX: provider should be physical ("local"), sdk should be "cirq" + assert loaded.record["backend"]["provider"] == "local" + assert loaded.record["backend"]["sdk"] == "cirq" assert loaded.record["execute"]["repetitions"] == repetitions - # Envelope internally consistent + # Envelope internally consistent (UEC 1.0) env = _load_single_envelope(store, loaded) - assert env["adapter"] == "cirq" + assert env["producer"]["adapter"] == "devqubit-cirq" + assert env["producer"]["sdk"] == "cirq" assert env["program"]["num_circuits"] == 1 assert env["execution"]["shots"] == repetitions - # Bell state should only produce 00 and 11 - counts = env["result"]["counts"][0]["counts"] + # Bell state should only produce 00 and 11 (UEC 1.0: items instead of counts) + result_items = env["result"]["items"] + assert len(result_items) == 1 + counts = result_items[0]["counts"]["counts"] assert sum(counts.values()) == repetitions assert set(counts).issubset({"00", "11"}) @@ -108,7 +113,8 @@ def test_produces_multiple_experiments( env = _load_single_envelope(store, loaded) assert env["execution"]["options"]["sweep"] is True - assert env["result"]["num_experiments"] == len(params) + # UEC 1.0: count items instead of num_experiments field + assert len(env["result"]["items"]) == len(params) def test_logs_params_per_experiment( self, parameterized_circuit, simulator, store, registry @@ -312,6 +318,81 @@ def test_every_n(self, simulator, store, registry): assert len(env_arts) == 3 +class TestFailurePathEnvelope: + """P0: Tests for failure-path envelope logging.""" + + def test_run_failure_logs_envelope_and_reraises(self, store, registry): + """When simulator.run() raises, envelope is logged and exception re-raised.""" + + class FailingSimulator: + """Simulator that always fails.""" + + def run(self, *args, **kwargs): + raise RuntimeError("Simulated hardware failure") + + def run_sweep(self, *args, **kwargs): + raise RuntimeError("Simulated hardware failure") + + def run_batch(self, *args, **kwargs): + raise RuntimeError("Simulated hardware failure") + + q0 = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.H(q0), cirq.measure(q0, key="m")) + + with track(project="test", store=store, registry=registry) as run: + # Import adapter directly to use wrap_executor + from devqubit_cirq.adapter import CirqAdapter + + adapter = CirqAdapter() + tracked = adapter.wrap_executor(FailingSimulator(), run) + + # Should raise the original exception + with pytest.raises(RuntimeError, match="Simulated hardware failure"): + tracked.run(circuit, repetitions=10) + + # Envelope should still be logged with failure info + loaded = registry.load(run.run_id) + env_arts = _artifacts_of_kind(loaded, "devqubit.envelope.json") + + # Should have logged an envelope even though execution failed + assert len(env_arts) == 1 + + env = _load_json(store, env_arts[0].digest) + assert env["result"]["success"] is False + assert env["result"]["status"] == "failed" + # UEC 1.0: error is a top-level field, not in metadata + assert env["result"]["error"]["type"] == "RuntimeError" + + def test_run_sweep_failure_logs_envelope(self, store, registry): + """When simulator.run_sweep() raises, envelope is logged.""" + + class FailingSweepSimulator: + def run_sweep(self, *args, **kwargs): + raise ValueError("Invalid sweep parameters") + + q0 = cirq.LineQubit(0) + theta = sympy.Symbol("theta") + circuit = cirq.Circuit(cirq.rz(theta).on(q0), cirq.measure(q0, key="m")) + params = [cirq.ParamResolver({theta: v}) for v in [0.0, 0.5]] + + with track(project="test", store=store, registry=registry) as run: + from devqubit_cirq.adapter import CirqAdapter + + adapter = CirqAdapter() + tracked = adapter.wrap_executor(FailingSweepSimulator(), run) + + with pytest.raises(ValueError, match="Invalid sweep parameters"): + tracked.run_sweep(circuit, params, repetitions=10) + + loaded = registry.load(run.run_id) + env_arts = _artifacts_of_kind(loaded, "devqubit.envelope.json") + assert len(env_arts) == 1 + + env = _load_json(store, env_arts[0].digest) + assert env["result"]["success"] is False + assert env["result"]["status"] == "failed" + + class TestMaterializeCircuits: """Tests for circuit materialization.""" diff --git a/packages/devqubit-cirq/tests/test_cirq_results.py b/packages/devqubit-cirq/tests/test_cirq_results.py index ff2c2f3..d5ea193 100644 --- a/packages/devqubit-cirq/tests/test_cirq_results.py +++ b/packages/devqubit-cirq/tests/test_cirq_results.py @@ -147,6 +147,11 @@ class MockResult: assert exp["measurement_keys"] == ["m"] assert exp["num_bits"] == 1 + # P1 FIX: Verify format metadata is present + assert "format" in payload + assert payload["format"]["source_sdk"] == "cirq" + assert payload["format"]["bit_order"] == "cbit0_left" + def test_list_of_results(self): """Normalizes list of results (run_sweep output).""" @@ -162,6 +167,9 @@ def __init__(self, val): assert payload["experiments"][1]["counts"] == {"1": 1} assert payload["experiments"][2]["counts"] == {"0": 1} + # P1 FIX: Format should be present + assert payload["format"]["source_sdk"] == "cirq" + def test_nested_results(self): """Normalizes nested results (run_batch output).""" @@ -182,10 +190,19 @@ def __init__(self, val): assert payload["experiments"][1]["sweep_index"] == 1 assert payload["experiments"][2]["batch_index"] == 1 + # P1 FIX: Format should be present + assert payload["format"]["source_sdk"] == "cirq" + assert payload["format"]["bit_order"] == "cbit0_left" + def test_empty_or_invalid(self): """Returns empty experiments for empty/invalid input.""" - assert normalize_counts_payload([]) == {"experiments": []} - assert normalize_counts_payload("not a result") == {"experiments": []} + empty_payload = normalize_counts_payload([]) + assert empty_payload["experiments"] == [] + assert "format" in empty_payload # Format still present + + invalid_payload = normalize_counts_payload("not a result") + assert invalid_payload["experiments"] == [] + assert "format" in invalid_payload def test_extracts_params_from_result(self): """Extracts params attribute from result objects.""" diff --git a/packages/devqubit-cirq/tests/test_cirq_snapshot.py b/packages/devqubit-cirq/tests/test_cirq_snapshot.py index 500c19f..6c8fb6f 100644 --- a/packages/devqubit-cirq/tests/test_cirq_snapshot.py +++ b/packages/devqubit-cirq/tests/test_cirq_snapshot.py @@ -13,7 +13,8 @@ def test_simulator_snapshot(self, simulator): """Creates valid snapshot from Simulator.""" snapshot = create_device_snapshot(simulator) - assert snapshot.provider == "cirq" + # P1 FIX: provider should be physical ("local"), not "cirq" + assert snapshot.provider == "local" assert snapshot.backend_name == "Simulator" assert snapshot.backend_type == "simulator" assert snapshot.captured_at is not None @@ -25,7 +26,7 @@ def test_density_matrix_simulator_snapshot(self, density_matrix_simulator): """Creates snapshot from DensityMatrixSimulator.""" snapshot = create_device_snapshot(density_matrix_simulator) - assert snapshot.provider == "cirq" + assert snapshot.provider == "local" # Local simulator assert snapshot.backend_name == "DensityMatrixSimulator" assert snapshot.backend_type == "simulator" @@ -34,8 +35,8 @@ def test_snapshot_serializes(self, simulator): snapshot = create_device_snapshot(simulator) d = snapshot.to_dict() - assert d["schema"] == "devqubit.device_snapshot/0.1" - assert d["provider"] == "cirq" + assert d["schema"] == "devqubit.device_snapshot/1.0" # UEC 1.0 + assert d["provider"] == "local" # P1 FIX: physical provider assert d["backend_name"] == "Simulator" assert d["backend_type"] == "simulator" assert "captured_at" in d @@ -77,6 +78,7 @@ class MockEngineSampler: snapshot = create_device_snapshot(MockEngineSampler()) assert snapshot.backend_type == "hardware" + assert snapshot.provider == "google_quantum" # P1 FIX: physical provider def test_processor_detected_as_hardware(self): """Processor samplers are detected as hardware.""" @@ -86,6 +88,7 @@ class MockProcessorSampler: snapshot = create_device_snapshot(MockProcessorSampler()) assert snapshot.backend_type == "hardware" + assert snapshot.provider == "google_quantum" # P1 FIX: physical provider def test_ionq_detected_as_hardware(self): """IonQ samplers are detected as hardware.""" @@ -95,6 +98,7 @@ class MockIonQSampler: snapshot = create_device_snapshot(MockIonQSampler()) assert snapshot.backend_type == "hardware" + assert snapshot.provider == "ionq" # P1 FIX: physical provider class TestSdkVersions: @@ -186,7 +190,7 @@ class SamplerWithConnectivity: # Connectivity may or may not be extracted depending on implementation # The key test is that it doesn't crash - assert snapshot.provider == "cirq" + assert snapshot.provider == "local" # P1 FIX: physical provider for cirq.sim def test_resilient_to_broken_metadata(self): """Handles broken device metadata gracefully.""" diff --git a/packages/devqubit-engine/src/devqubit_engine/core/run.py b/packages/devqubit-engine/src/devqubit_engine/core/run.py index 15187b7..d3d190b 100644 --- a/packages/devqubit-engine/src/devqubit_engine/core/run.py +++ b/packages/devqubit-engine/src/devqubit_engine/core/run.py @@ -131,7 +131,10 @@ def _compute_fingerprints(run: RunRecord) -> dict[str, str]: if not isinstance(backend, dict): backend = {} - device_digests = get_artifact_digests(run, role="device_snapshot") + device_snapshot_digests = get_artifact_digests(run, role="device_snapshot") + device_raw_digests = get_artifact_digests(run, role="device_raw") + device_digests = sorted(set(device_snapshot_digests + device_raw_digests)) + fp_device = sha256_digest( { "backend": { @@ -295,7 +298,7 @@ def __init__( # Initialize record structure self.record: dict[str, Any] = { - "schema": "devqubit.run/0.1", + "schema": "devqubit.run/1.0", "run_id": self._run_id, "created_at": utc_now_iso(), "project": {"name": project}, @@ -685,7 +688,7 @@ def log_envelope(self, envelope: ExecutionEnvelope) -> bool: validation = envelope.validate_schema() # Log based on validation result - if validation.valid: + if validation.ok: # Log valid envelope self.log_json( name="execution_envelope", @@ -698,15 +701,15 @@ def log_envelope(self, envelope: ExecutionEnvelope) -> bool: # Log validation error for debugging logger.warning( "Envelope validation failed (continuing): %d errors", - len(validation.errors), + validation.error_count, ) - # Log validation errors + # Log validation errors - iterate over validation.errors explicitly self.log_json( name="envelope_validation_error", obj={ - "errors": [str(e) for e in validation], - "error_count": len(validation.errors), + "errors": [str(e) for e in validation.errors], + "error_count": validation.error_count, }, role="config", kind="devqubit.envelope.validation_error.json", @@ -715,7 +718,7 @@ def log_envelope(self, envelope: ExecutionEnvelope) -> bool: # Store summary in tracker record for visibility self.record["envelope_validation_error"] = { "errors": [str(e) for e in validation.errors], - "count": len(validation.errors), + "count": validation.error_count, } # Log invalid envelope for debugging diff --git a/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.0.1.schema.json b/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.0.1.schema.json deleted file mode 100644 index 64e9f29..0000000 --- a/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.0.1.schema.json +++ /dev/null @@ -1,604 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://devqubit.dev/schemas/devqubit.envelope.0.1.schema.json", - "title": "devqubit.envelope/0.1", - "description": "Schema for quantum execution envelope in devqubit - the unified container for device, program, execution, and result snapshots.", - "type": "object", - - "properties": { - "schema": { - "const": "devqubit.envelope/0.1", - "description": "Schema version identifier." - }, - - "envelope_id": { - "type": "string", - "minLength": 1, - "description": "Unique envelope identifier for linking." - }, - - "created_at": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when the envelope was created." - }, - - "adapter": { - "type": "string", - "minLength": 1, - "description": "Adapter name that produced this envelope (e.g., 'qiskit', 'braket')." - }, - - "device": { - "$ref": "#/$defs/device_snapshot", - "description": "Device/backend state at execution time." - }, - - "program": { - "$ref": "#/$defs/program_snapshot", - "description": "Program artifacts (logical and physical)." - }, - - "execution": { - "$ref": "#/$defs/execution_snapshot", - "description": "Submission and job tracking metadata." - }, - - "result": { - "$ref": "#/$defs/result_snapshot", - "description": "Execution results and normalized summaries." - } - }, - - "required": ["schema"], - - "$defs": { - "artifact_ref": { - "type": "object", - "description": "Reference to a content-addressed artifact in the object store.", - "required": ["kind", "digest", "media_type", "role"], - "properties": { - "kind": { - "type": "string", - "minLength": 3, - "description": "Artifact type identifier (e.g., 'qiskit.qpy.circuits')." - }, - "digest": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$", - "description": "Content digest for retrieval from object store." - }, - "media_type": { - "type": "string", - "minLength": 3, - "description": "MIME type of the artifact content." - }, - "role": { - "type": "string", - "minLength": 1, - "description": "Logical role (e.g., 'program', 'results', 'device_snapshot')." - }, - "meta": { - "type": "object", - "description": "Additional artifact metadata.", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - - "frontend_config": { - "type": "object", - "description": "Frontend/primitive configuration for multi-layer SDK stacks.", - "required": ["name", "sdk"], - "properties": { - "name": { - "type": "string", - "description": "Frontend identifier (e.g., 'SamplerV2', 'braket.aws.qubit')." - }, - "sdk": { - "type": "string", - "description": "SDK/framework name (e.g., 'qiskit_runtime', 'pennylane')." - }, - "sdk_version": { - "type": "string", - "description": "SDK version string." - }, - "config": { - "type": "object", - "description": "Frontend-specific configuration options.", - "additionalProperties": true - } - }, - "additionalProperties": false - }, - - "qubit_calibration": { - "type": "object", - "description": "Per-qubit calibration record.", - "required": ["qubit"], - "properties": { - "qubit": { - "type": "integer", - "minimum": 0, - "description": "Qubit index (0-based)." - }, - "t1_us": { - "type": "number", - "minimum": 0, - "description": "Energy relaxation time (T1) in microseconds." - }, - "t2_us": { - "type": "number", - "minimum": 0, - "description": "Dephasing time (T2) in microseconds." - }, - "readout_error": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Assignment/readout error probability." - }, - "gate_error_1q": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Representative single-qubit gate error probability." - }, - "frequency_ghz": { - "type": "number", - "description": "Qubit frequency in GHz." - }, - "anharmonicity_ghz": { - "type": "number", - "description": "Qubit anharmonicity in GHz." - } - }, - "additionalProperties": false - }, - - "gate_calibration": { - "type": "object", - "description": "Per-gate calibration record.", - "required": ["gate", "qubits"], - "properties": { - "gate": { - "type": "string", - "description": "Gate name (e.g., 'cx', 'cz', 'rx')." - }, - "qubits": { - "type": "array", - "items": { "type": "integer", "minimum": 0 }, - "description": "Qubit indices the gate acts on." - }, - "error": { - "type": "number", - "minimum": 0, - "maximum": 1, - "description": "Gate error probability." - }, - "duration_ns": { - "type": "number", - "minimum": 0, - "description": "Gate duration in nanoseconds." - } - }, - "additionalProperties": false - }, - - "device_calibration": { - "type": "object", - "description": "Device-level calibration bundle.", - "properties": { - "schema": { - "type": "string", - "description": "Calibration schema version." - }, - "calibration_time": { - "type": "string", - "description": "Provider calibration timestamp." - }, - "qubits": { - "type": "array", - "items": { "$ref": "#/$defs/qubit_calibration" }, - "description": "Per-qubit calibration records." - }, - "gates": { - "type": "array", - "items": { "$ref": "#/$defs/gate_calibration" }, - "description": "Per-gate calibration records." - }, - "median_t1_us": { - "type": "number", - "description": "Median T1 across all qubits." - }, - "median_t2_us": { - "type": "number", - "description": "Median T2 across all qubits." - }, - "median_readout_error": { - "type": "number", - "description": "Median readout error across all qubits." - }, - "median_2q_error": { - "type": "number", - "description": "Median two-qubit gate error." - }, - "source": { - "type": "string", - "enum": ["provider", "derived", "manual"], - "description": "Data source indicator." - } - }, - "additionalProperties": true - }, - - "device_snapshot": { - "type": "object", - "description": "Point-in-time snapshot of a quantum backend.", - "required": ["captured_at", "backend_name", "backend_type", "provider"], - "properties": { - "schema": { - "type": "string", - "description": "Snapshot schema version." - }, - "captured_at": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when snapshot was captured." - }, - "backend_name": { - "type": "string", - "minLength": 1, - "description": "Backend identifier/name." - }, - "backend_type": { - "type": "string", - "enum": ["simulator", "hardware", "emulator", "sim", "hw", "qpu"], - "description": "Backend type category." - }, - "provider": { - "type": "string", - "minLength": 1, - "description": "Physical provider identifier." - }, - "backend_id": { - "type": "string", - "description": "Stable unique identifier (ARN, resource name)." - }, - "num_qubits": { - "type": "integer", - "minimum": 1, - "description": "Number of qubits on the backend." - }, - "connectivity": { - "type": "array", - "items": { - "type": "array", - "items": { "type": "integer" }, - "minItems": 2, - "maxItems": 2 - }, - "description": "Edge list of connected qubit pairs." - }, - "native_gates": { - "type": "array", - "items": { "type": "string" }, - "description": "List of native gate names." - }, - "calibration": { - "$ref": "#/$defs/device_calibration", - "description": "Calibration data bundle." - }, - "frontend": { - "$ref": "#/$defs/frontend_config", - "description": "Frontend configuration for multi-layer stacks." - }, - "sdk_versions": { - "type": "object", - "additionalProperties": { "type": "string" }, - "description": "SDK version strings for all involved layers." - }, - "raw_properties_ref": { - "$ref": "#/$defs/artifact_ref", - "description": "Reference to raw backend properties artifact." - } - }, - "additionalProperties": true - }, - - "program_artifact": { - "type": "object", - "description": "Program artifact with role and format information.", - "required": ["ref", "role", "format"], - "properties": { - "ref": { - "$ref": "#/$defs/artifact_ref", - "description": "Reference to the artifact in object store." - }, - "role": { - "type": "string", - "enum": ["logical", "physical", "transpiled"], - "description": "Role in the execution pipeline." - }, - "format": { - "type": "string", - "description": "Format identifier (e.g., 'qpy', 'openqasm3')." - }, - "name": { - "type": "string", - "description": "Human-readable name for the circuit." - }, - "index": { - "type": "integer", - "minimum": 0, - "description": "Index in a batch of circuits." - } - }, - "additionalProperties": false - }, - - "program_snapshot": { - "type": "object", - "description": "Program artifacts with logical/physical distinction.", - "properties": { - "schema": { - "type": "string", - "description": "Program snapshot schema version." - }, - "logical": { - "type": "array", - "items": { "$ref": "#/$defs/program_artifact" }, - "description": "User-provided circuits before transpilation." - }, - "physical": { - "type": "array", - "items": { "$ref": "#/$defs/program_artifact" }, - "description": "Transpiled circuits conforming to backend ISA." - }, - "program_hash": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$", - "description": "Hash of the logical program(s)." - }, - "executed_hash": { - "type": "string", - "pattern": "^sha256:[0-9a-f]{64}$", - "description": "Hash of the physical/executed program(s)." - }, - "num_circuits": { - "type": "integer", - "minimum": 1, - "description": "Number of circuits in this program." - }, - "openqasm3_anchors": { - "type": "array", - "items": { "type": "object" }, - "description": "OpenQASM 3 anchor references." - } - }, - "additionalProperties": true - }, - - "transpilation_info": { - "type": "object", - "description": "Transpilation/compilation details.", - "properties": { - "mode": { - "type": "string", - "enum": ["auto", "manual", "managed"], - "description": "Transpilation handling mode." - }, - "transpiled_by": { - "type": "string", - "enum": ["devqubit", "user", "provider"], - "description": "Who performed transpilation." - }, - "pass_manager_config": { - "type": "object", - "description": "Pass manager configuration used.", - "additionalProperties": true - }, - "optimization_level": { - "type": "integer", - "minimum": 0, - "maximum": 3, - "description": "Optimization level used." - }, - "layout": { - "type": "object", - "description": "Qubit layout mapping (virtual → physical).", - "additionalProperties": { "type": "integer" } - }, - "layout_method": { - "type": "string", - "description": "Layout method used." - }, - "routing_method": { - "type": "string", - "description": "Routing method used." - }, - "seed": { - "type": "integer", - "description": "Random seed for reproducibility." - } - }, - "additionalProperties": true - }, - - "execution_snapshot": { - "type": "object", - "description": "Execution submission and job tracking metadata.", - "properties": { - "schema": { - "type": "string", - "description": "Execution snapshot schema version." - }, - "submitted_at": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when job was submitted." - }, - "completed_at": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp when job completed." - }, - "shots": { - "type": "integer", - "minimum": 1, - "description": "Number of shots/repetitions." - }, - "job_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Provider job identifiers." - }, - "task_ids": { - "type": "array", - "items": { "type": "string" }, - "description": "Provider task identifiers." - }, - "execution_count": { - "type": "integer", - "minimum": 1, - "description": "Number of circuit executions." - }, - "transpilation": { - "$ref": "#/$defs/transpilation_info", - "description": "Transpilation/compilation details." - }, - "queue_info": { - "type": "object", - "description": "Queue position and timing information.", - "additionalProperties": true - }, - "options": { - "type": "object", - "description": "Additional execution options.", - "additionalProperties": true - }, - "sdk": { - "type": "string", - "description": "SDK used for submission." - } - }, - "additionalProperties": true - }, - - "normalized_counts": { - "type": "object", - "description": "Normalized measurement counts from a single circuit.", - "required": ["circuit_index", "counts"], - "properties": { - "circuit_index": { - "type": "integer", - "minimum": 0, - "description": "Index of the circuit in a batch." - }, - "counts": { - "type": "object", - "additionalProperties": { "type": "integer", "minimum": 0 }, - "description": "Bitstring → count mapping." - }, - "shots": { - "type": "integer", - "minimum": 1, - "description": "Total number of shots." - }, - "name": { - "type": "string", - "description": "Circuit name if available." - } - }, - "additionalProperties": false - }, - - "normalized_expectation": { - "type": "object", - "description": "Normalized expectation value result.", - "required": ["circuit_index", "observable_index", "value"], - "properties": { - "circuit_index": { - "type": "integer", - "minimum": 0, - "description": "Index of the circuit in a batch." - }, - "observable_index": { - "type": "integer", - "minimum": 0, - "description": "Index of the observable." - }, - "value": { - "type": "number", - "description": "Expectation value." - }, - "variance": { - "type": "number", - "description": "Variance of the expectation value." - }, - "std_error": { - "type": "number", - "description": "Standard error of the expectation value." - }, - "observable": { - "type": "string", - "description": "String representation of the observable." - } - }, - "additionalProperties": false - }, - - "result_snapshot": { - "type": "object", - "description": "Execution results with raw payload reference and normalized summaries.", - "required": ["result_type"], - "properties": { - "schema": { - "type": "string", - "description": "Result snapshot schema version." - }, - "result_type": { - "type": "string", - "enum": ["counts", "quasi_dist", "expectation", "samples", "statevector", "density_matrix"], - "description": "Type of quantum execution result." - }, - "raw_result_ref": { - "$ref": "#/$defs/artifact_ref", - "description": "Reference to raw result payload artifact." - }, - "counts": { - "type": "array", - "items": { "$ref": "#/$defs/normalized_counts" }, - "description": "Normalized measurement counts per circuit." - }, - "expectations": { - "type": "array", - "items": { "$ref": "#/$defs/normalized_expectation" }, - "description": "Normalized expectation values." - }, - "num_experiments": { - "type": "integer", - "minimum": 1, - "description": "Number of experiments/circuits in the result." - }, - "success": { - "type": "boolean", - "description": "Whether execution completed successfully." - }, - "error_message": { - "type": "string", - "description": "Error message if execution failed." - }, - "metadata": { - "type": "object", - "description": "Additional result metadata.", - "additionalProperties": true - } - }, - "additionalProperties": true - } - }, - - "additionalProperties": true -} diff --git a/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.1.0.schema.json b/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.1.0.schema.json new file mode 100644 index 0000000..aac6a7b --- /dev/null +++ b/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.envelope.1.0.schema.json @@ -0,0 +1,533 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://devqubit.io/schema/devqubit.envelope.1.0.schema.json", + "title": "devqubit.envelope/1.0", + "description": "UEC ExecutionEnvelope schema with production requirements", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "envelope_id", + "created_at", + "producer", + "result" + ], + "properties": { + "schema": { + "const": "devqubit.envelope/1.0" + }, + "envelope_id": { + "type": "string", + "minLength": 20, + "maxLength": 40, + "description": "ULID or UUID identifier" + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "RFC3339 timestamp" + }, + "producer": { + "$ref": "#/$defs/producer_info" + }, + "program": { + "$ref": "#/$defs/program_snapshot" + }, + "device": { + "$ref": "#/$defs/device_snapshot" + }, + "execution": { + "$ref": "#/$defs/execution_snapshot" + }, + "result": { + "$ref": "#/$defs/result_snapshot" + }, + "metadata": { + "type": "object", + "description": "Additional metadata (extensible)" + } + }, + "$defs": { + "producer_info": { + "type": "object", + "description": "Complete SDK stack that produced this envelope", + "additionalProperties": false, + "required": [ + "name", + "adapter", + "frontends" + ], + "properties": { + "name": { + "const": "devqubit" + }, + "engine_version": { + "type": "string" + }, + "adapter": { + "type": "string", + "description": "Adapter package name (devqubit-qiskit, devqubit-braket, etc.)" + }, + "adapter_version": { + "type": "string" + }, + "sdk": { + "type": "string", + "description": "Primary SDK (qiskit, braket-sdk, pennylane, cirq)" + }, + "sdk_version": { + "type": "string" + }, + "frontends": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "description": "SDK stack from highest to lowest level" + }, + "build": { + "type": "object", + "additionalProperties": false, + "properties": { + "commit": { + "type": "string" + }, + "dirty": { + "type": "boolean" + }, + "branch": { + "type": "string" + } + } + } + } + }, + "program_snapshot": { + "type": "object", + "required": [ + "schema" + ], + "properties": { + "schema": { + "const": "devqubit.program_snapshot/1.0" + }, + "logical": { + "type": "array", + "items": { + "$ref": "#/$defs/program_artifact" + } + }, + "physical": { + "type": "array", + "items": { + "$ref": "#/$defs/program_artifact" + } + }, + "program_hash": { + "type": "string" + }, + "num_circuits": { + "type": "integer", + "minimum": 0 + }, + "transpilation": { + "type": "object" + } + } + }, + "program_artifact": { + "type": "object", + "required": [ + "ref", + "role", + "format" + ], + "properties": { + "ref": { + "$ref": "#/$defs/artifact_ref" + }, + "role": { + "type": "string", + "enum": ["logical", "physical"] + }, + "format": { + "type": "string" + }, + "name": { + "type": "string" + }, + "index": { + "type": "integer" + } + } + }, + "device_snapshot": { + "type": "object", + "required": [ + "schema" + ], + "properties": { + "schema": { + "const": "devqubit.device_snapshot/1.0" + }, + "captured_at": { + "type": "string", + "format": "date-time" + }, + "backend_name": { + "type": "string" + }, + "backend_type": { + "type": "string", + "enum": ["hardware", "simulator", "emulator"] + }, + "provider": { + "type": "string" + }, + "num_qubits": { + "type": "integer" + }, + "connectivity": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + } + }, + "native_gates": { + "type": "array", + "items": { + "type": "string" + } + }, + "calibration": { + "type": "object" + }, + "sdk_versions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "raw_properties_ref": { + "$ref": "#/$defs/artifact_ref" + } + } + }, + "execution_snapshot": { + "type": "object", + "required": [ + "schema" + ], + "properties": { + "schema": { + "const": "devqubit.execution_snapshot/1.0" + }, + "submitted_at": { + "type": "string", + "format": "date-time" + }, + "completed_at": { + "type": "string", + "format": "date-time" + }, + "shots": { + "type": "integer", + "minimum": 1 + }, + "execution_count": { + "type": "integer", + "minimum": 1 + }, + "job_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "transpilation": { + "type": "object" + }, + "options": { + "type": "object" + }, + "sdk": { + "type": "string" + } + } + }, + "result_snapshot": { + "type": "object", + "description": "Execution results with explicit success/status", + "required": [ + "schema", + "success", + "status", + "items" + ], + "properties": { + "schema": { + "const": "devqubit.result_snapshot/1.0" + }, + "success": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "completed", + "failed", + "cancelled", + "partial" + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/result_item" + }, + "description": "Results as list (always present, may be empty)" + }, + "error": { + "$ref": "#/$defs/result_error" + }, + "raw_result_ref": { + "$ref": "#/$defs/artifact_ref" + }, + "metadata": { + "type": "object" + } + } + }, + "result_item": { + "type": "object", + "description": "Single item in batch results", + "required": [ + "item_index", + "success" + ], + "properties": { + "item_index": { + "type": "integer", + "minimum": 0 + }, + "success": { + "type": "boolean" + }, + "counts": { + "$ref": "#/$defs/normalized_counts" + }, + "quasi_probability": { + "$ref": "#/$defs/quasi_probability" + }, + "expectation": { + "$ref": "#/$defs/normalized_expectation" + }, + "raw_ref": { + "$ref": "#/$defs/artifact_ref" + }, + "error_message": { + "type": "string" + } + } + }, + "normalized_counts": { + "type": "object", + "required": [ + "counts", + "shots", + "format" + ], + "properties": { + "counts": { + "type": "object", + "additionalProperties": { + "type": "integer" + }, + "description": "Bitstring to count mapping" + }, + "shots": { + "type": "integer", + "minimum": 1 + }, + "format": { + "$ref": "#/$defs/counts_format" + } + } + }, + "counts_format": { + "type": "object", + "description": "Metadata about how counts are formatted", + "required": [ + "source_sdk", + "bit_order" + ], + "properties": { + "source_sdk": { + "type": "string", + "description": "SDK that produced the raw counts" + }, + "source_key_format": { + "type": "string", + "description": "Original format identifier" + }, + "bit_order": { + "type": "string", + "enum": [ + "cbit0_right", + "cbit0_left" + ], + "description": "Canonical is cbit0_right (LSB on right)" + }, + "transformed": { + "type": "boolean", + "description": "Whether adapter remapped keys" + }, + "num_clbits": { + "type": "integer", + "minimum": 0 + }, + "registers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "size": { + "type": "integer" + } + } + } + } + } + }, + "quasi_probability": { + "type": "object", + "description": "Quasi-probability distribution from error mitigation", + "required": [ + "distribution" + ], + "properties": { + "distribution": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Bitstring to probability (may be negative)" + }, + "precision": { + "type": "number", + "description": "Rounding precision applied" + }, + "sum_probs": { + "type": "number" + }, + "min_prob": { + "type": "number" + }, + "max_prob": { + "type": "number" + } + } + }, + "normalized_expectation": { + "type": "object", + "required": [ + "circuit_index", + "observable_index", + "value" + ], + "properties": { + "circuit_index": { + "type": "integer", + "minimum": 0 + }, + "observable_index": { + "type": "integer", + "minimum": 0 + }, + "value": { + "type": "number" + }, + "variance": { + "type": "number" + }, + "std_error": { + "type": "number" + }, + "observable": { + "type": "string" + } + } + }, + "result_error": { + "type": "object", + "description": "Structured error information", + "required": [ + "type", + "message" + ], + "properties": { + "type": { + "type": "string", + "description": "Error class name" + }, + "message": { + "type": "string" + }, + "stack_hash": { + "type": "string", + "description": "Hash of stack trace for grouping" + }, + "retryable": { + "type": "boolean" + }, + "details": { + "type": "object" + } + } + }, + "artifact_ref": { + "type": "object", + "description": "Content-addressed artifact reference", + "required": [ + "kind", + "digest", + "media_type", + "role" + ], + "properties": { + "kind": { + "type": "string", + "minLength": 3, + "description": "Artifact type identifier" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$", + "description": "Content digest (sha256:hex)" + }, + "media_type": { + "type": "string", + "minLength": 3, + "description": "MIME type" + }, + "role": { + "type": "string", + "description": "Logical role" + }, + "meta": { + "type": "object", + "description": "Additional metadata" + } + } + } + } +} diff --git a/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.0.1.schema.json b/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.1.0.schema.json similarity index 98% rename from packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.0.1.schema.json rename to packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.1.0.schema.json index 00cc321..0f711ad 100644 --- a/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.0.1.schema.json +++ b/packages/devqubit-engine/src/devqubit_engine/schema/devqubit.run.1.0.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://devqubit.dev/schemas/devqubit.run.0.1.schema.json", - "title": "devqubit.run/0.1", + "$id": "https://devqubit.dev/schemas/devqubit.run.1.0.schema.json", + "title": "devqubit.run/1.0", "description": "Schema for quantum experiment run records in devqubit.", "type": "object", "required": ["schema", "run_id", "created_at", "project", "adapter", "data", "artifacts"], @@ -35,7 +35,7 @@ "properties": { "schema": { - "const": "devqubit.run/0.1", + "const": "devqubit.run/1.0", "description": "Schema version identifier." }, diff --git a/packages/devqubit-engine/src/devqubit_engine/schema/validation.py b/packages/devqubit-engine/src/devqubit_engine/schema/validation.py index 0aebd2d..a09209b 100644 --- a/packages/devqubit-engine/src/devqubit_engine/schema/validation.py +++ b/packages/devqubit-engine/src/devqubit_engine/schema/validation.py @@ -10,8 +10,8 @@ Supported Schemas ----------------- -- ``devqubit.run/0.1``: Quantum experiment run records -- ``devqubit.envelope/0.1``: Execution envelope snapshots (device, program, +- ``devqubit.run/1.0``: Quantum experiment run records +- ``devqubit.envelope/1.0``: Execution envelope snapshots (device, program, execution, result) Notes @@ -36,8 +36,8 @@ # Mapping of schema IDs to bundled schema filenames _SCHEMA_MAP: dict[str, str] = { - "devqubit.run/0.1": "devqubit.run.0.1.schema.json", - "devqubit.envelope/0.1": "devqubit.envelope.0.1.schema.json", + "devqubit.run/1.0": "devqubit.run.1.0.schema.json", + "devqubit.envelope/1.0": "devqubit.envelope.1.0.schema.json", } @@ -49,7 +49,7 @@ def _load_schema(schema_id: str) -> dict[str, Any]: Parameters ---------- schema_id : str - Schema identifier (e.g., "devqubit.run/0.1", "devqubit.envelope/0.1"). + Schema identifier (e.g., "devqubit.run/1.0", "devqubit.envelope/1.0"). Returns ------- @@ -291,7 +291,7 @@ def validate_run_record( ---------- record : dict Run record dictionary to validate. Must contain a "schema" field - identifying the schema version (e.g., "devqubit.run/0.1"). + identifying the schema version (e.g., "devqubit.run/1.0"). raise_on_error : bool, optional If True (default), raise ValueError on validation failure. If False, return the list of validation errors. @@ -326,7 +326,7 @@ def validate_envelope( ---------- envelope : dict Envelope dictionary to validate. Must contain a "schema" field - identifying the schema version (e.g., "devqubit.envelope/0.1"). + identifying the schema version (e.g., "devqubit.envelope/1.0"). raise_on_error : bool, optional If True (default), raise ValueError on validation failure. If False, return the list of validation errors. diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/__init__.py b/packages/devqubit-engine/src/devqubit_engine/uec/__init__.py index f5a2bc5..4f0929a 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/__init__.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/__init__.py @@ -4,15 +4,26 @@ """ Uniform Execution Contract (UEC) snapshot schemas. -This module provides standardized types for capturing quantum experiment state -across all supported SDKs. The UEC defines four canonical snapshot types that -every adapter must produce, plus a unified envelope container. +This module provides standardized types for capturing quantum experiment +state across all supported SDKs. The UEC defines canonical snapshot types +that every adapter must produce, plus a unified envelope container. + +Requirements +------------ +- ``producer`` is REQUIRED (SDK stack tracking) +- ``result.success`` and ``result.status`` are REQUIRED +- ``result.items[]`` is always a list (even single executions) +- ``counts.format`` MUST describe bit ordering +- ``quasi_probabilities`` are first-class (IBM Runtime) Snapshot Hierarchy ------------------ ExecutionEnvelope Top-level container unifying all snapshots for a single execution. + ProducerInfo + SDK stack information for debug/compatibility. + DeviceSnapshot Point-in-time capture of quantum backend state. @@ -30,4 +41,13 @@ ResultSnapshot Raw result references and normalized summaries. + + ResultItem + Per-item results for batch executions. + + CountsFormat + Bit ordering and source SDK metadata. + + QuasiProbability + Quasi-distributions from error mitigation. """ diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/device.py b/packages/devqubit-engine/src/devqubit_engine/uec/device.py index d933104..c18e16c 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/device.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/device.py @@ -112,7 +112,7 @@ class DeviceSnapshot: sdk_versions: dict[str, str] = field(default_factory=dict) raw_properties_ref: ArtifactRef | None = None - schema_version: str = "devqubit.device_snapshot/0.1" + schema_version: str = "devqubit.device_snapshot/1.0" def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -173,7 +173,7 @@ def from_dict(cls, d: dict[str, Any]) -> DeviceSnapshot: frontend=frontend, sdk_versions=d.get("sdk_versions", {}), raw_properties_ref=raw_properties_ref, - schema_version=d.get("schema", "devqubit.device_snapshot/0.1"), + schema_version=d.get("schema", "devqubit.device_snapshot/1.0"), ) def get_calibration_summary(self) -> dict[str, Any] | None: diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/envelope.py b/packages/devqubit-engine/src/devqubit_engine/uec/envelope.py index 5007432..920c66d 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/envelope.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/envelope.py @@ -6,24 +6,47 @@ This module defines ExecutionEnvelope which unifies all four canonical snapshots (device, program, execution, result) into a single record. + +Schema Requirements (devqubit.envelope/1.0) +------------------------------------------- +REQUIRED fields: +- schema: "devqubit.envelope/1.0" +- envelope_id: ULID or UUID +- created_at: RFC3339 timestamp +- producer: ProducerInfo +- result: ResultSnapshot (with success/status) + +Use ExecutionEnvelope.create() factory to ensure all required fields. """ from __future__ import annotations import logging -from dataclasses import dataclass +import uuid +from dataclasses import dataclass, field from typing import Any from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.producer import ProducerInfo from devqubit_engine.uec.program import ProgramSnapshot from devqubit_engine.uec.result import ResultSnapshot from devqubit_engine.uec.types import ValidationResult +from devqubit_engine.utils.time_utils import utc_now_iso logger = logging.getLogger(__name__) +def _generate_envelope_id() -> str: + """ + Generate a unique envelope ID. + + Returns a UUID4 without hyphens (26 chars, matches schema pattern). + """ + return uuid.uuid4().hex[:26] + + @dataclass class ExecutionEnvelope: """ @@ -34,37 +57,62 @@ class ExecutionEnvelope: Parameters ---------- + envelope_id : str + Unique envelope identifier (ULID/UUID format). + created_at : str + Creation timestamp (RFC3339 format). + producer : ProducerInfo + SDK stack information. + result : ResultSnapshot + Execution results (required by schema). device : DeviceSnapshot, optional Device/backend state at execution time. program : ProgramSnapshot, optional Program artifacts (logical and physical circuits). execution : ExecutionSnapshot, optional Execution metadata and configuration. - result : ResultSnapshot, optional - Execution results. - adapter : str, optional - Adapter that created this envelope. - envelope_id : str, optional - Unique envelope identifier. - created_at : str, optional - Creation timestamp. schema_version : str Schema version identifier. + metadata : dict, optional + Additional metadata. + + Notes + ----- + Use the ``create()`` factory method to ensure all required fields + are properly initialized with defaults. """ + # REQUIRED fields (per schema) + envelope_id: str + created_at: str + producer: ProducerInfo + result: ResultSnapshot + + # Optional snapshots device: DeviceSnapshot | None = None program: ProgramSnapshot | None = None execution: ExecutionSnapshot | None = None - result: ResultSnapshot | None = None - - adapter: str | None = None - envelope_id: str | None = None - created_at: str | None = None - schema_version: str = "devqubit.envelope/0.1" + # Schema and metadata + schema_version: str = "devqubit.envelope/1.0" + metadata: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"schema": self.schema_version} + """ + Convert to JSON-serializable dictionary. + + Returns + ------- + dict + Dictionary matching devqubit.envelope/1.0 schema. + """ + d: dict[str, Any] = { + "schema": self.schema_version, + "envelope_id": self.envelope_id, + "created_at": self.created_at, + "producer": self.producer.to_dict(), + "result": self.result.to_dict(), + } if self.device: d["device"] = self.device.to_dict() @@ -72,19 +120,31 @@ def to_dict(self) -> dict[str, Any]: d["program"] = self.program.to_dict() if self.execution: d["execution"] = self.execution.to_dict() - if self.result: - d["result"] = self.result.to_dict() - if self.adapter: - d["adapter"] = self.adapter - if self.envelope_id: - d["envelope_id"] = self.envelope_id - if self.created_at: - d["created_at"] = self.created_at + if self.metadata: + d["metadata"] = self.metadata return d @classmethod def from_dict(cls, d: dict[str, Any]) -> ExecutionEnvelope: + """ + Create ExecutionEnvelope from dictionary. + + Parameters + ---------- + d : dict + Dictionary with envelope fields. + + Returns + ------- + ExecutionEnvelope + Parsed envelope. + """ + # Required fields + producer = ProducerInfo.from_dict(d.get("producer", {})) + result = ResultSnapshot.from_dict(d.get("result", {})) + + # Optional snapshots device = None if isinstance(d.get("device"), dict): device = DeviceSnapshot.from_dict(d["device"]) @@ -97,19 +157,73 @@ def from_dict(cls, d: dict[str, Any]) -> ExecutionEnvelope: if isinstance(d.get("execution"), dict): execution = ExecutionSnapshot.from_dict(d["execution"]) - result = None - if isinstance(d.get("result"), dict): - result = ResultSnapshot.from_dict(d["result"]) - return cls( + envelope_id=str(d.get("envelope_id", _generate_envelope_id())), + created_at=str(d.get("created_at", utc_now_iso())), + producer=producer, + result=result, device=device, program=program, execution=execution, + schema_version=str(d.get("schema", "devqubit.envelope/1.0")), + metadata=d.get("metadata", {}), + ) + + @classmethod + def create( + cls, + *, + producer: ProducerInfo, + result: ResultSnapshot | None = None, + device: DeviceSnapshot | None = None, + program: ProgramSnapshot | None = None, + execution: ExecutionSnapshot | None = None, + metadata: dict[str, Any] | None = None, + ) -> ExecutionEnvelope: + """ + Factory method to create envelope with auto-generated ID and timestamp. + + This is the recommended way to create envelopes - ensures all + required fields are properly initialized. + + Parameters + ---------- + producer : ProducerInfo + SDK stack information (required). + result : ResultSnapshot, optional + Execution results. If None, creates empty failed result. + device : DeviceSnapshot, optional + Device snapshot. + program : ProgramSnapshot, optional + Program snapshot. + execution : ExecutionSnapshot, optional + Execution snapshot. + metadata : dict, optional + Additional metadata. + + Returns + ------- + ExecutionEnvelope + New envelope with generated envelope_id and created_at. + """ + # Ensure result is never None (schema requires it) + if result is None: + result = ResultSnapshot( + success=False, + status="failed", + items=[], + metadata={"reason": "No result provided"}, + ) + + return cls( + envelope_id=_generate_envelope_id(), + created_at=utc_now_iso(), + producer=producer, result=result, - adapter=d.get("adapter"), - envelope_id=d.get("envelope_id"), - created_at=d.get("created_at"), - schema_version=d.get("schema", "devqubit.envelope/0.1"), + device=device, + program=program, + execution=execution, + metadata=metadata or {}, ) def validate(self) -> list[str]: @@ -117,9 +231,20 @@ def validate(self) -> list[str]: Validate envelope completeness (semantic validation). Returns a list of warnings for missing or incomplete data. + This is NOT schema validation - use validate_schema() for that. + + Returns + ------- + list of str + Warning messages for missing data. """ warnings: list[str] = [] + if not self.envelope_id: + warnings.append("Missing envelope_id") + if not self.created_at: + warnings.append("Missing created_at") + if not self.device: warnings.append("Missing device snapshot") elif not self.device.backend_name: @@ -133,10 +258,8 @@ def validate(self) -> list[str]: if not self.execution: warnings.append("Missing execution snapshot") - if not self.result: - warnings.append("Missing result snapshot") - elif self.result.success is False and not self.result.error_message: - warnings.append("Failed result missing error_message") + if self.result.success is False and self.result.error is None: + warnings.append("Failed result missing error details") return warnings @@ -145,6 +268,11 @@ def validate_schema(self) -> ValidationResult: Validate envelope against JSON Schema. Returns ValidationResult with valid flag, errors, and warnings. + + Returns + ------- + ValidationResult + Validation result with errors list. """ try: from devqubit_engine.schema.validation import validate_envelope @@ -204,7 +332,7 @@ def resolve_physical_backend(executor: Any) -> dict[str, Any] | None: "provider": "unknown", "backend_name": "unknown", "backend_id": None, - "backend_type": "unknown", + "backend_type": "simulator", "backend_obj": executor, } @@ -225,9 +353,25 @@ def resolve_physical_backend(executor: Any) -> dict[str, Any] | None: name_lower = result["backend_name"].lower() type_lower = executor_type.lower() - if any(s in name_lower or s in type_lower for s in ("sim", "emulator", "fake")): - result["backend_type"] = "simulator" - elif any(s in name_lower for s in ("ibm_", "ionq", "rigetti", "oqc", "aspen")): + if any( + s in name_lower + for s in ( + "ibm_", + "ionq", + "rigetti", + "oqc", + "aspen", + ) + ): result["backend_type"] = "hardware" + elif any( + s in name_lower or s in type_lower + for s in ( + "sim", + "emulator", + "fake", + ) + ): + result["backend_type"] = "simulator" return result diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/execution.py b/packages/devqubit-engine/src/devqubit_engine/uec/execution.py index 27c07fd..aaa79f8 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/execution.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/execution.py @@ -53,7 +53,7 @@ class ExecutionSnapshot: sdk: str | None = None completed_at: str | None = None - schema_version: str = "devqubit.execution_snapshot/0.1" + schema_version: str = "devqubit.execution_snapshot/1.0" def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -94,5 +94,5 @@ def from_dict(cls, d: dict[str, Any]) -> ExecutionSnapshot: options=d.get("options", {}), sdk=d.get("sdk"), completed_at=d.get("completed_at"), - schema_version=d.get("schema", "devqubit.execution_snapshot/0.1"), + schema_version=d.get("schema", "devqubit.execution_snapshot/1.0"), ) diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/producer.py b/packages/devqubit-engine/src/devqubit_engine/uec/producer.py new file mode 100644 index 0000000..2d65bf1 --- /dev/null +++ b/packages/devqubit-engine/src/devqubit_engine/uec/producer.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Producer information for UEC. + +This module defines ProducerInfo which captures the complete SDK +stack that produced an execution envelope. This is critical for: + +- Debug: Understanding which SDK versions were involved +- Compatibility: Detecting version mismatches +- Reproducibility: Recreating the exact environment + +Examples +-------- +Simple Qiskit setup: + +>>> producer = ProducerInfo.create( +... adapter="devqubit-qiskit", +... adapter_version="0.3.0", +... sdk="qiskit", +... sdk_version="1.3.0", +... frontends=["qiskit"], +... ) + +Multi-layer PennyLane → Braket stack: + +>>> producer = ProducerInfo.create( +... adapter="devqubit-pennylane", +... adapter_version="0.2.0", +... sdk="braket-sdk", +... sdk_version="1.80.0", +... frontends=["pennylane", "amazon-braket-pennylane-plugin", "braket-sdk"], +... ) +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + + +logger = logging.getLogger(__name__) + + +def _get_engine_version() -> str: + """ + Get devqubit-engine version from package metadata. + + Returns + ------- + str + Version string or "unknown" if not installed. + """ + try: + from importlib.metadata import version + + return version("devqubit-engine") + except Exception: + return "unknown" + + +@dataclass +class ProducerInfo: + """ + SDK stack information for the envelope producer. + + Captures the complete toolchain that produced an execution envelope, + from the high-level frontend down to the physical backend SDK. + + Parameters + ---------- + name : str + Producer name. Always "devqubit" for devqubit-engine. + engine_version : str + devqubit-engine version string. + adapter : str + Adapter identifier (e.g., "devqubit-qiskit", "devqubit-braket"). + adapter_version : str + Adapter version string. + sdk : str + Primary/lowest SDK name (e.g., "qiskit", "braket-sdk", "cirq"). + sdk_version : str + Primary SDK version string. + frontends : list of str + SDK stack from highest to lowest layer. + E.g., ["pennylane", "amazon-braket-pennylane-plugin", "braket-sdk"] + For simple setups: ["qiskit"] + """ + + name: str + engine_version: str + adapter: str + adapter_version: str + sdk: str + sdk_version: str + frontends: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + """Validate required fields.""" + if not self.frontends: + raise ValueError( + "frontends must be a non-empty list. " + "For simple setups, use [sdk_name]. " + "For multi-layer stacks, list from highest to lowest." + ) + + def to_dict(self) -> dict[str, Any]: + """ + Convert to JSON-serializable dictionary. + + Returns + ------- + dict + Dictionary with all producer fields. + """ + d: dict[str, Any] = { + "name": self.name, + "engine_version": self.engine_version, + "adapter": self.adapter, + "adapter_version": self.adapter_version, + "sdk": self.sdk, + "sdk_version": self.sdk_version, + "frontends": self.frontends, + } + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> ProducerInfo: + """ + Create ProducerInfo from dictionary. + + Parameters + ---------- + d : dict + Dictionary with producer fields. + + Returns + ------- + ProducerInfo + Parsed producer info. + """ + return cls( + name=str(d.get("name", "devqubit")), + engine_version=str(d.get("engine_version", "unknown")), + adapter=str(d.get("adapter", "")), + adapter_version=str(d.get("adapter_version", "")), + sdk=str(d.get("sdk", "")), + sdk_version=str(d.get("sdk_version", "")), + frontends=d.get("frontends", ["unknown"]), + ) + + @classmethod + def create( + cls, + *, + adapter: str, + adapter_version: str, + sdk: str, + sdk_version: str, + frontends: list[str], + ) -> ProducerInfo: + """ + Create ProducerInfo with auto-detected engine version. + + This is the recommended factory method for adapters. + + Parameters + ---------- + adapter : str + Adapter identifier (e.g., "devqubit-qiskit"). + adapter_version : str + Adapter version string. + sdk : str + Primary SDK name (e.g., "qiskit", "braket-sdk"). + sdk_version : str + Primary SDK version string. + frontends : list of str + SDK stack from highest to lowest layer. + + Returns + ------- + ProducerInfo + Configured producer info with auto-detected engine version. + """ + return cls( + name="devqubit", + engine_version=_get_engine_version(), + adapter=adapter, + adapter_version=adapter_version, + sdk=sdk, + sdk_version=sdk_version, + frontends=frontends, + ) diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/program.py b/packages/devqubit-engine/src/devqubit_engine/uec/program.py index 39a083b..c497b21 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/program.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/program.py @@ -158,7 +158,7 @@ class ProgramSnapshot: num_circuits: int | None = None transpilation: TranspilationInfo | None = None - schema_version: str = "devqubit.program_snapshot/0.1" + schema_version: str = "devqubit.program_snapshot/1.0" def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -199,5 +199,5 @@ def from_dict(cls, d: dict[str, Any]) -> ProgramSnapshot: executed_hash=d.get("executed_hash"), num_circuits=d.get("num_circuits"), transpilation=transpilation, - schema_version=d.get("schema", "devqubit.program_snapshot/0.1"), + schema_version=d.get("schema", "devqubit.program_snapshot/1.0"), ) diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/result.py b/packages/devqubit-engine/src/devqubit_engine/uec/result.py index 2376906..4336e21 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/result.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/result.py @@ -5,64 +5,206 @@ Result snapshot for capturing execution results. This module defines ResultSnapshot and normalized result types -for measurement counts and expectation values. +for measurement counts, quasi-probabilities, and expectation values. + +Canonical Bit Order +------------------- +UEC standard: ``cbit0_right`` (little-endian string, LSB on right) +- Qiskit: native little-endian → no transformation needed +- Braket: big-endian → adapter must reverse bitstrings +- Cirq: big-endian integers → adapter must reverse bitstrings """ from __future__ import annotations +import hashlib +import traceback from dataclasses import dataclass, field from typing import Any -from devqubit_engine.uec.types import ArtifactRef, ResultType +from devqubit_engine.uec.types import ArtifactRef + + +# ============================================================================= +# Counts Format Metadata +# ============================================================================= @dataclass -class NormalizedCounts: +class CountsFormat: """ - Normalized measurement counts for a single circuit. + Metadata describing the format of measurement counts. + + Required when counts are present. Describes bit ordering convention + and source SDK to enable cross-SDK comparison. Parameters ---------- - circuit_index : int - Circuit index in batch. - counts : dict - Measurement counts (bitstring → count). - shots : int, optional - Total shots for this circuit. - name : str, optional - Circuit name. + source_sdk : str + SDK that produced the raw counts (qiskit, braket, cirq, pennylane). + source_key_format : str + Original format identifier describing how keys were encoded. + Examples: "qiskit_little_endian", "qiskit_register_spaced", + "braket_big_endian", "cirq_big_endian_int", "hex", "0b_prefixed". + bit_order : str + Bit ordering convention for the counts keys. + Canonical is "cbit0_right" (LSB on right, like Qiskit). + transformed : bool + Whether the adapter transformed keys to canonical format. + num_clbits : int, optional + Number of classical bits (for padding/validation). + registers : list, optional + Register layout metadata for multi-register circuits. """ - circuit_index: int - counts: dict[str, int] - shots: int | None = None - name: str | None = None + source_sdk: str + source_key_format: str + bit_order: str = "cbit0_right" + transformed: bool = False + num_clbits: int | None = None + registers: list[dict[str, Any]] | None = None def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" d: dict[str, Any] = { - "circuit_index": self.circuit_index, - "counts": self.counts, + "source_sdk": self.source_sdk, + "source_key_format": self.source_key_format, + "bit_order": self.bit_order, + "transformed": self.transformed, } - if self.shots is not None: - d["shots"] = self.shots - if self.name: - d["name"] = self.name + if self.num_clbits is not None: + d["num_clbits"] = self.num_clbits + if self.registers: + d["registers"] = self.registers return d @classmethod - def from_dict(cls, d: dict[str, Any]) -> NormalizedCounts: + def from_dict(cls, d: dict[str, Any]) -> CountsFormat: + """Create from dictionary.""" return cls( - circuit_index=int(d.get("circuit_index", 0)), - counts=d.get("counts", {}), - shots=d.get("shots"), - name=d.get("name"), + source_sdk=str(d.get("source_sdk", "")), + source_key_format=str(d.get("source_key_format", "")), + bit_order=str(d.get("bit_order", "cbit0_right")), + transformed=bool(d.get("transformed", False)), + num_clbits=d.get("num_clbits"), + registers=d.get("registers"), ) +# ============================================================================= +# Quasi-Probability Distribution +# ============================================================================= + + +@dataclass +class QuasiProbability: + """ + Quasi-probability distribution from error-mitigated execution. + + IBM Runtime Sampler returns quasi-distributions that may contain + negative probabilities due to error mitigation techniques. + + Parameters + ---------- + distribution : dict + Bitstring to quasi-probability mapping. Values may be negative. + precision : float, optional + Rounding precision applied by the runtime. + sum_probs : float, optional + Sum of all probabilities (ideally 1.0). + min_prob : float, optional + Minimum probability value (may be negative). + max_prob : float, optional + Maximum probability value. + """ + + distribution: dict[str, float] + precision: float | None = None + sum_probs: float | None = None + min_prob: float | None = None + max_prob: float | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" + d: dict[str, Any] = {"distribution": self.distribution} + if self.precision is not None: + d["precision"] = self.precision + if self.sum_probs is not None: + d["sum_probs"] = self.sum_probs + if self.min_prob is not None: + d["min_prob"] = self.min_prob + if self.max_prob is not None: + d["max_prob"] = self.max_prob + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> QuasiProbability: + """Create from dictionary.""" + return cls( + distribution=d.get("distribution", {}), + precision=d.get("precision"), + sum_probs=d.get("sum_probs"), + min_prob=d.get("min_prob"), + max_prob=d.get("max_prob"), + ) + + @classmethod + def from_quasi_dist( + cls, + quasi_dist: dict[int | str, float], + num_clbits: int | None = None, + precision: float | None = None, + ) -> QuasiProbability: + """ + Create from IBM Runtime quasi-distribution. + + Handles integer keys (common in SamplerResult) by converting + to bitstrings. + + Parameters + ---------- + quasi_dist : dict + Quasi-distribution from IBM Runtime (int or str keys). + num_clbits : int, optional + Number of classical bits for bitstring padding. + precision : float, optional + Precision value from runtime. + + Returns + ------- + QuasiProbability + Structured quasi-probability with computed stats. + """ + distribution: dict[str, float] = {} + for key, prob in quasi_dist.items(): + if isinstance(key, int): + if num_clbits: + bitstring = format(key, f"0{num_clbits}b") + else: + bitstring = bin(key)[2:] + else: + bitstring = str(key) + distribution[bitstring] = float(prob) + + probs = list(distribution.values()) + return cls( + distribution=distribution, + precision=precision, + sum_probs=sum(probs) if probs else None, + min_prob=min(probs) if probs else None, + max_prob=max(probs) if probs else None, + ) + + +# ============================================================================= +# Normalized Expectation Value +# ============================================================================= + + @dataclass class NormalizedExpectation: """ - Normalized expectation value result with full metadata. + Normalized expectation value result. Parameters ---------- @@ -88,6 +230,7 @@ class NormalizedExpectation: observable: str | None = None def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" d: dict[str, Any] = { "circuit_index": self.circuit_index, "observable_index": self.observable_index, @@ -103,6 +246,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, d: dict[str, Any]) -> NormalizedExpectation: + """Create from dictionary.""" return cls( circuit_index=int(d.get("circuit_index", 0)), observable_index=int(d.get("observable_index", 0)), @@ -113,140 +257,419 @@ def from_dict(cls, d: dict[str, Any]) -> NormalizedExpectation: ) +# ============================================================================= +# Result Error +# ============================================================================= + + @dataclass -class ExpectationValue: +class ResultError: """ - Simple expectation value result. + Structured error information for failed executions. Parameters ---------- - circuit_index : int - Circuit index. - observable_index : int - Observable index. - value : float - Expectation value. - std_error : float, optional - Standard error. + type : str + Exception class name (e.g., "TimeoutError", "IBMRuntimeError"). + message : str + Short error message. + stack_hash : str, optional + Hash of stack trace for grouping similar errors. + retryable : bool, optional + Whether the error is likely transient and retryable. + details : dict, optional + Additional error context. """ - circuit_index: int - observable_index: int - value: float - std_error: float | None = None + type: str + message: str + stack_hash: str | None = None + retryable: bool | None = None + details: dict[str, Any] | None = None def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" d: dict[str, Any] = { - "circuit_index": self.circuit_index, - "observable_index": self.observable_index, - "value": self.value, + "type": self.type, + "message": self.message, } - if self.std_error is not None: - d["std_error"] = self.std_error + if self.stack_hash: + d["stack_hash"] = self.stack_hash + if self.retryable is not None: + d["retryable"] = self.retryable + if self.details: + d["details"] = self.details return d @classmethod - def from_dict(cls, d: dict[str, Any]) -> ExpectationValue: + def from_dict(cls, d: dict[str, Any]) -> ResultError: + """Create from dictionary.""" return cls( - circuit_index=int(d.get("circuit_index", 0)), - observable_index=int(d.get("observable_index", 0)), - value=float(d.get("value", 0.0)), - std_error=d.get("std_error"), + type=str(d.get("type", "UnknownError")), + message=str(d.get("message", "")), + stack_hash=d.get("stack_hash"), + retryable=d.get("retryable"), + details=d.get("details"), ) + @classmethod + def from_exception( + cls, + exc: BaseException, + retryable: bool | None = None, + ) -> ResultError: + """ + Create from Python exception. + + Parameters + ---------- + exc : BaseException + The exception to convert. + retryable : bool, optional + Override retryable detection. + + Returns + ------- + ResultError + Structured error with stack hash. + """ + tb = traceback.format_exception(type(exc), exc, exc.__traceback__) + stack_str = "".join(tb) + stack_hash = hashlib.sha256(stack_str.encode()).hexdigest()[:16] + + if retryable is None: + retryable_types = ( + "TimeoutError", + "ConnectionError", + "TransientError", + "ServiceUnavailable", + "RateLimitError", + ) + retryable = type(exc).__name__ in retryable_types + + return cls( + type=type(exc).__name__, + message=str(exc)[:500], + stack_hash=stack_hash, + retryable=retryable, + ) + + +# ============================================================================= +# Result Item (per-item in batch) +# ============================================================================= + @dataclass -class ResultSnapshot: +class ResultItem: """ - Result snapshot. + Single item in batch execution results. + + Each item in ``result.items[]`` represents results for one circuit + or parameter set. For single-circuit runs, ``items`` has one element. Parameters ---------- - result_type : ResultType - Type of result. - raw_result_ref : ArtifactRef, optional - Reference to raw result artifact. - counts : list of NormalizedCounts - Normalized measurement counts. - expectations : list of ExpectationValue - Expectation values. - num_experiments : int, optional - Number of experiments. + item_index : int + Position in the batch (0-based). success : bool - Whether execution succeeded. + Whether this item succeeded. + counts : dict, optional + Measurement counts with format metadata. + Structure: {"counts": {...}, "shots": N, "format": CountsFormat} + quasi_probability : QuasiProbability, optional + Quasi-probability distribution (IBM Runtime Sampler). + expectation : NormalizedExpectation, optional + Expectation value result. + raw_ref : ArtifactRef, optional + Reference to raw SDK result artifact. error_message : str, optional - Error message if failed. + Error message if this item failed. + + Notes + ----- + Exactly ONE of counts, quasi_probability, or expectation should be set. + """ + + item_index: int + success: bool + counts: dict[str, Any] | None = None + quasi_probability: QuasiProbability | None = None + expectation: NormalizedExpectation | None = None + raw_ref: ArtifactRef | None = None + error_message: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" + d: dict[str, Any] = { + "item_index": self.item_index, + "success": self.success, + } + if self.counts is not None: + d["counts"] = self.counts + if self.quasi_probability is not None: + d["quasi_probability"] = self.quasi_probability.to_dict() + if self.expectation is not None: + d["expectation"] = self.expectation.to_dict() + if self.raw_ref is not None: + d["raw_ref"] = self.raw_ref.to_dict() + if self.error_message: + d["error_message"] = self.error_message + return d + + @classmethod + def from_dict(cls, d: dict[str, Any]) -> ResultItem: + """Create from dictionary.""" + quasi = None + if d.get("quasi_probability"): + quasi = QuasiProbability.from_dict(d["quasi_probability"]) + + exp = None + if d.get("expectation"): + exp = NormalizedExpectation.from_dict(d["expectation"]) + + raw_ref = None + if d.get("raw_ref"): + raw_ref = ArtifactRef.from_dict(d["raw_ref"]) + + return cls( + item_index=int(d.get("item_index", 0)), + success=bool(d.get("success", True)), + counts=d.get("counts"), + quasi_probability=quasi, + expectation=exp, + raw_ref=raw_ref, + error_message=d.get("error_message"), + ) + + @classmethod + def from_counts( + cls, + item_index: int, + counts: dict[str, int], + shots: int, + format_info: CountsFormat, + raw_ref: ArtifactRef | None = None, + ) -> ResultItem: + """ + Create ResultItem from measurement counts. + + Parameters + ---------- + item_index : int + Position in batch. + counts : dict + Bitstring to count mapping. + shots : int + Total shots. + format_info : CountsFormat + Counts format metadata. + raw_ref : ArtifactRef, optional + Reference to raw result. + + Returns + ------- + ResultItem + Configured result item with counts. + """ + return cls( + item_index=item_index, + success=True, + counts={ + "counts": counts, + "shots": shots, + "format": format_info.to_dict(), + }, + raw_ref=raw_ref, + ) + + +# ============================================================================= +# Result Snapshot +# ============================================================================= + + +@dataclass +class ResultSnapshot: + """ + Result snapshot with success/status/items structure. + + Parameters + ---------- + success : bool + Overall execution success. + status : str + Normalized status: "completed", "failed", "cancelled", "partial". + items : list of ResultItem + Results for each item in batch. Always a list. + error : ResultError, optional + Structured error information if failed. + raw_result_ref : ArtifactRef, optional + Reference to complete raw result artifact. metadata : dict Additional metadata. + + Notes + ----- + Use factory methods for common cases: + - ``ResultSnapshot.create_success()`` for successful results + - ``ResultSnapshot.create_failed()`` for exception handling + - ``ResultSnapshot.create_partial()`` for partial failures """ - result_type: ResultType + success: bool + status: str + items: list[ResultItem] = field(default_factory=list) + error: ResultError | None = None raw_result_ref: ArtifactRef | None = None - counts: list[NormalizedCounts] = field(default_factory=list) - expectations: list[ExpectationValue] = field(default_factory=list) - num_experiments: int | None = None - success: bool = True - error_message: str | None = None metadata: dict[str, Any] = field(default_factory=dict) - schema_version: str = "devqubit.result_snapshot/0.1" + schema_version: str = "devqubit.result_snapshot/1.0" + + def __post_init__(self) -> None: + """Validate status values.""" + valid_statuses = ("completed", "failed", "cancelled", "partial") + if self.status not in valid_statuses: + raise ValueError( + f"status must be one of {valid_statuses}, got: {self.status}" + ) def to_dict(self) -> dict[str, Any]: + """Convert to JSON-serializable dictionary.""" d: dict[str, Any] = { "schema": self.schema_version, - "result_type": ( - self.result_type.value - if hasattr(self.result_type, "value") - else str(self.result_type) - ), "success": self.success, + "status": self.status, + "items": [item.to_dict() for item in self.items], } - if self.raw_result_ref: + if self.error is not None: + d["error"] = self.error.to_dict() + if self.raw_result_ref is not None: d["raw_result_ref"] = self.raw_result_ref.to_dict() - if self.counts: - d["counts"] = [c.to_dict() for c in self.counts] - if self.expectations: - d["expectations"] = [e.to_dict() for e in self.expectations] - if self.num_experiments is not None: - d["num_experiments"] = self.num_experiments - if self.error_message: - d["error_message"] = self.error_message if self.metadata: d["metadata"] = self.metadata return d @classmethod def from_dict(cls, d: dict[str, Any]) -> ResultSnapshot: - result_type_val = d.get("result_type", "counts") - result_type = ( - ResultType(result_type_val) - if isinstance(result_type_val, str) - else result_type_val - ) + """Create from dictionary.""" + items = [ + ResultItem.from_dict(x) for x in d.get("items", []) if isinstance(x, dict) + ] - raw_result_ref = None - if isinstance(d.get("raw_result_ref"), dict): - raw_result_ref = ArtifactRef.from_dict(d["raw_result_ref"]) + error = None + if d.get("error"): + error = ResultError.from_dict(d["error"]) - counts = [ - NormalizedCounts.from_dict(x) - for x in d.get("counts", []) - if isinstance(x, dict) - ] - expectations = [ - ExpectationValue.from_dict(x) - for x in d.get("expectations", []) - if isinstance(x, dict) - ] + raw_ref = None + if d.get("raw_result_ref"): + raw_ref = ArtifactRef.from_dict(d["raw_result_ref"]) return cls( - result_type=result_type, - raw_result_ref=raw_result_ref, - counts=counts, - expectations=expectations, - num_experiments=d.get("num_experiments"), - success=d.get("success", True), - error_message=d.get("error_message"), + success=bool(d.get("success", False)), + status=str(d.get("status", "failed")), + items=items, + error=error, + raw_result_ref=raw_ref, metadata=d.get("metadata", {}), - schema_version=d.get("schema", "devqubit.result_snapshot/0.1"), + schema_version=d.get("schema", "devqubit.result_snapshot/1.0"), + ) + + @classmethod + def create_success( + cls, + items: list[ResultItem], + raw_result_ref: ArtifactRef | None = None, + metadata: dict[str, Any] | None = None, + ) -> ResultSnapshot: + """ + Create successful result snapshot. + + Parameters + ---------- + items : list of ResultItem + Results for each batch item. + raw_result_ref : ArtifactRef, optional + Reference to raw result. + metadata : dict, optional + Additional metadata. + + Returns + ------- + ResultSnapshot + Successful result snapshot. + """ + return cls( + success=True, + status="completed", + items=items, + raw_result_ref=raw_result_ref, + metadata=metadata or {}, + ) + + @classmethod + def create_failed( + cls, + exception: BaseException, + partial_items: list[ResultItem] | None = None, + metadata: dict[str, Any] | None = None, + ) -> ResultSnapshot: + """ + Create failed result snapshot from exception. + + This is the recommended way to handle failures - ensures + envelope is always created even on exceptions. + + Parameters + ---------- + exception : BaseException + The exception that caused failure. + partial_items : list of ResultItem, optional + Any partial results obtained before failure. + metadata : dict, optional + Additional context. + + Returns + ------- + ResultSnapshot + Failed result snapshot with structured error. + """ + return cls( + success=False, + status="failed", + items=partial_items or [], + error=ResultError.from_exception(exception), + metadata=metadata or {}, + ) + + @classmethod + def create_partial( + cls, + items: list[ResultItem], + error: ResultError | None = None, + metadata: dict[str, Any] | None = None, + ) -> ResultSnapshot: + """ + Create partial result snapshot (some items succeeded). + + Parameters + ---------- + items : list of ResultItem + Mix of successful and failed items. + error : ResultError, optional + Overall error information. + metadata : dict, optional + Additional metadata. + + Returns + ------- + ResultSnapshot + Partial result snapshot. + """ + return cls( + success=False, + status="partial", + items=items, + error=error, + metadata=metadata or {}, ) diff --git a/packages/devqubit-engine/src/devqubit_engine/uec/types.py b/packages/devqubit-engine/src/devqubit_engine/uec/types.py index 48036f0..da4c74c 100644 --- a/packages/devqubit-engine/src/devqubit_engine/uec/types.py +++ b/packages/devqubit-engine/src/devqubit_engine/uec/types.py @@ -188,4 +188,23 @@ class ValidationResult: warnings: list[str] = field(default_factory=list) def __bool__(self) -> bool: + """Return True if validation passed.""" return self.valid + + def __iter__(self): + """Iterate over validation errors.""" + return iter(self.errors) + + def __len__(self) -> int: + """Return number of validation errors.""" + return len(self.errors) + + @property + def ok(self) -> bool: + """Alias for valid - returns True if no errors.""" + return self.valid + + @property + def error_count(self) -> int: + """Return the number of validation errors.""" + return len(self.errors) diff --git a/packages/devqubit-engine/src/devqubit_engine/utils/serialization.py b/packages/devqubit-engine/src/devqubit_engine/utils/serialization.py index 5b107ae..6b12c3c 100644 --- a/packages/devqubit-engine/src/devqubit_engine/utils/serialization.py +++ b/packages/devqubit-engine/src/devqubit_engine/utils/serialization.py @@ -2,11 +2,30 @@ # SPDX-FileCopyrightText: 2026 devqubit """ -JSON serialization utilities. +Deterministic JSON serialization utilities. This module provides functions for converting arbitrary Python objects -to JSON-serializable format, with robust handling for numpy arrays, -dataclasses, Pydantic models, and other common types. +to JSON-serializable format with **deterministic output** suitable for +fingerprinting, hashing, and reproducible logging. + +Key guarantees for production use: + +- **Deterministic ordering**: `sort_keys=True` for all dicts +- **Stable separators**: Consistent across Python versions +- **Float normalization**: Controllable precision for reproducibility +- **Set/frozenset handling**: Converted to sorted lists + +Examples +-------- +Basic serialization: + +>>> safe_json_dumps({"b": 2, "a": 1}) +'{\\n "a": 1,\\n "b": 2\\n}' + +Canonical format for hashing: + +>>> canonical_json_dumps({"b": 2.123456789, "a": 1}) +'{"a":1,"b":2.12345678900000}' """ from __future__ import annotations @@ -22,8 +41,66 @@ # Maximum recursion depth to prevent infinite loops _MAX_DEPTH = 50 +# Default float precision for canonical serialization (15 significant digits) +_DEFAULT_FLOAT_PRECISION = 15 + -def to_jsonable(obj: Any, *, max_depth: int = _MAX_DEPTH) -> Any: +def _sort_key(v: Any) -> tuple[str, str]: + """ + Generate a stable sort key for heterogeneous collections. + + Parameters + ---------- + v : Any + Value to generate sort key for. + + Returns + ------- + tuple of (type_name, str_repr) + Tuple ensuring stable ordering across types. + """ + return (type(v).__name__, str(v)) + + +def _normalize_float( + value: float, + precision: int = _DEFAULT_FLOAT_PRECISION, +) -> float: + """ + Normalize a float to a specific precision. + + Parameters + ---------- + value : float + Float value to normalize. + precision : int + Number of significant digits. + + Returns + ------- + float + Normalized float value. + """ + if not isinstance(value, float): + return value + # Handle special values + if value != value: # NaN + return value + if value == float("inf") or value == float("-inf"): + return value + # Round to precision + if value == 0.0: + return 0.0 + return float(f"{value:.{precision}g}") + + +def to_jsonable( + obj: Any, + *, + max_depth: int = _MAX_DEPTH, + normalize_floats: bool = False, + float_precision: int = _DEFAULT_FLOAT_PRECISION, +) -> Any: """ Convert arbitrary Python objects to JSON-serializable format. @@ -38,6 +115,12 @@ def to_jsonable(obj: Any, *, max_depth: int = _MAX_DEPTH) -> Any: max_depth : int, optional Maximum recursion depth to prevent infinite loops in self-referential structures. Default is 50. + normalize_floats : bool, optional + If True, normalize floats to a specific precision for + deterministic output. Default is False. + float_precision : int, optional + Number of significant digits for float normalization. + Only used if normalize_floats is True. Default is 15. Returns ------- @@ -54,70 +137,128 @@ def to_jsonable(obj: Any, *, max_depth: int = _MAX_DEPTH) -> Any: 3. NumPy arrays - converted via ``.tolist()`` 4. Dicts - recursively convert values, stringify keys 5. Lists/tuples - recursively convert elements - 6. Dataclasses - convert via ``dataclasses.asdict()`` - 7. Pydantic models - try ``model_dump()``, then ``dict()`` - 8. Objects with ``to_dict()`` method - 9. Objects with ``__dict__`` attribute - 10. Fallback to ``repr()`` (truncated to 500 chars) + 6. Sets/frozensets - convert to **sorted** lists for determinism + 7. Dataclasses - convert via ``dataclasses.asdict()`` + 8. Pydantic models - try ``model_dump()``, then ``dict()`` + 9. Objects with ``to_dict()`` method + 10. Objects with ``__dict__`` attribute + 11. Fallback to ``repr()`` (truncated to 500 chars) + """ if max_depth <= 0: logger.debug("Max depth exceeded, truncating: %r", type(obj)) return {"__truncated__": repr(obj)[:100]} - # JSON primitives - return as-is - if obj is None or isinstance(obj, (str, int, float, bool)): + # JSON primitives - return as-is (with optional float normalization) + if obj is None or isinstance(obj, (str, bool)): + return obj + + if isinstance(obj, int) and not isinstance(obj, bool): + return obj + + if isinstance(obj, float): + if normalize_floats: + return _normalize_float(obj, float_precision) return obj # NumPy scalars (check before arrays since scalars also have tolist) if hasattr(obj, "item") and callable(obj.item): try: - return obj.item() + item = obj.item() + if normalize_floats and isinstance(item, float): + return _normalize_float(item, float_precision) + return item except (TypeError, ValueError): pass # NumPy arrays and array-like objects if hasattr(obj, "tolist") and callable(obj.tolist): try: - return obj.tolist() + return to_jsonable( + obj.tolist(), + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) except (TypeError, ValueError): pass # Dictionaries - recurse with depth limit if isinstance(obj, dict): - return {str(k): to_jsonable(v, max_depth=max_depth - 1) for k, v in obj.items()} + return { + str(k): to_jsonable( + v, + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) + for k, v in obj.items() + } # Lists and tuples - recurse with depth limit if isinstance(obj, (list, tuple)): - return [to_jsonable(v, max_depth=max_depth - 1) for v in obj] - - # Sets - convert to sorted list for deterministic output + return [ + to_jsonable( + v, + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) + for v in obj + ] + + # Sets/frozensets - convert to SORTED list for deterministic output if isinstance(obj, (set, frozenset)): + converted = [ + to_jsonable( + v, + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) + for v in obj + ] try: - return sorted(to_jsonable(v, max_depth=max_depth - 1) for v in obj) + return sorted(converted) except TypeError: - # Not sortable, just convert to list - return [to_jsonable(v, max_depth=max_depth - 1) for v in obj] + # Heterogeneous types - use stable sort key + return sorted(converted, key=_sort_key) # Dataclasses if is_dataclass(obj) and not isinstance(obj, type): try: - return to_jsonable(asdict(obj), max_depth=max_depth - 1) + return to_jsonable( + asdict(obj), + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) except (TypeError, ValueError): pass # Try common serialization methods (Pydantic v2, Pydantic v1, custom) - for method_name in ("model_dump", "dict", "to_dict", "to_dict"): + for method_name in ("model_dump", "dict", "to_dict"): method = getattr(obj, method_name, None) if callable(method): try: - return to_jsonable(method(), max_depth=max_depth - 1) + return to_jsonable( + method(), + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) except Exception: continue # Try __dict__ for generic objects if hasattr(obj, "__dict__"): try: - return to_jsonable(vars(obj), max_depth=max_depth - 1) + return to_jsonable( + vars(obj), + max_depth=max_depth - 1, + normalize_floats=normalize_floats, + float_precision=float_precision, + ) except Exception: pass @@ -144,12 +285,18 @@ def _default_serializer(obj: Any) -> Any: return str(obj) -def safe_json_dumps(obj: Any, *, indent: int | None = 2) -> str: +def safe_json_dumps( + obj: Any, + *, + indent: int | None = 2, + sort_keys: bool = True, +) -> str: """ - Serialize to JSON with robust fallback for unknown types. + Serialize to JSON with deterministic output and robust fallback. Combines :func:`to_jsonable` conversion with ``json.dumps``, - providing an additional fallback for any types that slip through. + providing deterministic ordering via ``sort_keys=True`` and stable + separators. Parameters ---------- @@ -158,14 +305,54 @@ def safe_json_dumps(obj: Any, *, indent: int | None = 2) -> str: indent : int or None, optional Indentation level for pretty-printing. Default is 2. Use None for compact output. + sort_keys : bool, optional + Whether to sort dictionary keys. Default is True for + deterministic output. + """ + return json.dumps( + to_jsonable(obj), + indent=indent, + sort_keys=sort_keys, + separators=(",", ":") if indent is None else (",", ": "), + default=_default_serializer, + ) + + +def canonical_json_dumps( + obj: Any, + *, + float_precision: int = _DEFAULT_FLOAT_PRECISION, +) -> str: + """ + Serialize to canonical JSON format suitable for hashing/fingerprinting. + + Produces the most compact, deterministic JSON representation: + + - No whitespace (compact) + - Sorted keys + - Normalized floats (configurable precision) + - Sets converted to sorted lists + + Parameters + ---------- + obj : Any + Object to serialize. + float_precision : int, optional + Number of significant digits for float normalization. + Default is 15 (full double precision). Returns ------- str - JSON string. + Canonical JSON string suitable for hashing. """ return json.dumps( - to_jsonable(obj), - indent=indent, + to_jsonable( + obj, + normalize_floats=True, + float_precision=float_precision, + ), + sort_keys=True, + separators=(",", ":"), # Most compact: no spaces default=_default_serializer, ) diff --git a/packages/devqubit-engine/tests/conftest.py b/packages/devqubit-engine/tests/conftest.py index 423e8f1..08196a7 100644 --- a/packages/devqubit-engine/tests/conftest.py +++ b/packages/devqubit-engine/tests/conftest.py @@ -21,6 +21,8 @@ QubitCalibration, ) from devqubit_engine.uec.device import DeviceSnapshot +from devqubit_engine.uec.producer import ProducerInfo +from devqubit_engine.uec.result import CountsFormat from devqubit_engine.uec.types import ArtifactRef @@ -274,6 +276,45 @@ def calibrated_snapshot(snapshot_factory, calibration_factory) -> DeviceSnapshot ) +# ============================================================================= +# UEC Result Fixtures +# ============================================================================= + + +@pytest.fixture +def qiskit_counts_format() -> CountsFormat: + """Standard Qiskit counts format (canonical bit order).""" + return CountsFormat( + source_sdk="qiskit", + source_key_format="qiskit_little_endian", + bit_order="cbit0_right", + transformed=False, + ) + + +@pytest.fixture +def braket_counts_format() -> CountsFormat: + """Braket counts format (transformed to canonical).""" + return CountsFormat( + source_sdk="braket", + source_key_format="braket_big_endian", + bit_order="cbit0_right", + transformed=True, + ) + + +@pytest.fixture +def minimal_producer() -> ProducerInfo: + """Minimal valid ProducerInfo for testing.""" + return ProducerInfo.create( + adapter="devqubit-test", + adapter_version="0.1.0", + sdk="test-sdk", + sdk_version="1.0.0", + frontends=["test-sdk"], + ) + + # ============================================================================= # Circuit Fixtures # ============================================================================= diff --git a/packages/devqubit-engine/tests/test_snapshot.py b/packages/devqubit-engine/tests/test_snapshot.py deleted file mode 100644 index 81ac37f..0000000 --- a/packages/devqubit-engine/tests/test_snapshot.py +++ /dev/null @@ -1,589 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: 2026 devqubit - -"""Tests for devqubit UEC snapshot types.""" - -from __future__ import annotations - -from unittest.mock import patch - -from devqubit_engine.uec.calibration import ( - DeviceCalibration, - GateCalibration, - QubitCalibration, -) -from devqubit_engine.uec.device import DeviceSnapshot, FrontendConfig -from devqubit_engine.uec.envelope import ExecutionEnvelope -from devqubit_engine.uec.execution import ExecutionSnapshot -from devqubit_engine.uec.program import ( - ProgramArtifact, - ProgramSnapshot, - TranspilationInfo, -) -from devqubit_engine.uec.result import NormalizedCounts, ResultSnapshot -from devqubit_engine.uec.types import ( - ArtifactRef, - ProgramRole, - ResultType, - TranspilationMode, - ValidationResult, -) - - -class TestQubitCalibration: - """Tests for QubitCalibration dataclass.""" - - def test_to_dict_excludes_none(self): - """to_dict only includes non-None values.""" - qc = QubitCalibration(qubit=0, t1_us=100.0) - d = qc.to_dict() - - assert d == {"qubit": 0, "t1_us": 100.0} - assert "t2_us" not in d - - def test_full_calibration_round_trip(self): - """Full calibration survives serialization round-trip.""" - original = QubitCalibration( - qubit=5, - t1_us=120.5, - t2_us=85.3, - readout_error=0.012, - gate_error_1q=0.001, - frequency_ghz=5.2, - anharmonicity_ghz=-0.33, - ) - restored = QubitCalibration.from_dict(original.to_dict()) - - assert restored.qubit == original.qubit - assert restored.t1_us == original.t1_us - assert restored.frequency_ghz == original.frequency_ghz - - -class TestGateCalibration: - """Tests for GateCalibration dataclass.""" - - def test_qubits_tuple_to_list_conversion(self): - """to_dict converts qubits tuple to list for JSON.""" - gc = GateCalibration(gate="cx", qubits=(0, 1), error=0.005) - d = gc.to_dict() - - assert d["qubits"] == [0, 1] - assert isinstance(d["qubits"], list) - - def test_round_trip_preserves_tuple(self): - """from_dict restores qubits as tuple.""" - gc = GateCalibration(gate="cx", qubits=(0, 1), error=0.01, duration_ns=300.0) - restored = GateCalibration.from_dict(gc.to_dict()) - - assert restored.qubits == (0, 1) - assert isinstance(restored.qubits, tuple) - - def test_is_two_qubit(self): - """is_two_qubit correctly identifies multi-qubit gates.""" - single = GateCalibration(gate="x", qubits=(0,)) - two = GateCalibration(gate="cx", qubits=(0, 1)) - - assert not single.is_two_qubit - assert two.is_two_qubit - - -class TestDeviceCalibration: - """Tests for DeviceCalibration dataclass.""" - - def test_compute_medians(self): - """compute_medians calculates correct median values.""" - cal = DeviceCalibration( - qubits=[ - QubitCalibration( - qubit=0, - t1_us=100.0, - t2_us=80.0, - readout_error=0.01, - ), - QubitCalibration( - qubit=1, - t1_us=120.0, - t2_us=90.0, - readout_error=0.02, - ), - QubitCalibration( - qubit=2, - t1_us=110.0, - t2_us=85.0, - readout_error=0.015, - ), - ], - gates=[ - GateCalibration( - gate="cx", - qubits=(0, 1), - error=0.01, - ), - GateCalibration( - gate="cx", - qubits=(1, 2), - error=0.02, - ), - ], - ) - cal.compute_medians() - - assert cal.median_t1_us == 110.0 - assert cal.median_t2_us == 85.0 - assert cal.median_readout_error == 0.015 - assert cal.median_2q_error == 0.015 - - def test_compute_medians_ignores_none(self): - """compute_medians skips qubits with missing values.""" - cal = DeviceCalibration( - qubits=[ - QubitCalibration(qubit=0, t1_us=100.0), - QubitCalibration(qubit=1, t1_us=None), - QubitCalibration(qubit=2, t1_us=120.0), - ], - ) - cal.compute_medians() - - assert cal.median_t1_us == 110.0 - - def test_to_dict_auto_computes_medians(self): - """to_dict triggers median computation if needed.""" - cal = DeviceCalibration( - qubits=[QubitCalibration(qubit=0, t1_us=100.0)], - ) - d = cal.to_dict() - - assert "median_t1_us" in d - assert d["median_t1_us"] == 100.0 - - def test_round_trip(self): - """Full calibration survives serialization.""" - cal = DeviceCalibration( - calibration_time="2024-01-01T10:00:00Z", - qubits=[QubitCalibration(qubit=0, t1_us=100.0)], - gates=[GateCalibration(gate="cx", qubits=(0, 1), error=0.01)], - source="provider", - ) - restored = DeviceCalibration.from_dict(cal.to_dict()) - - assert restored.calibration_time == cal.calibration_time - assert restored.source == "provider" - assert len(restored.qubits) == 1 - - def test_calibration_factory_fixture(self, calibration_factory): - """Fixture creates valid calibration with computed medians.""" - cal = calibration_factory(num_qubits=5) - - assert len(cal.qubits) == 5 - assert len(cal.gates) == 4 - assert cal.median_t1_us is not None - - -class TestFrontendConfig: - """Tests for FrontendConfig dataclass.""" - - def test_round_trip(self): - """FrontendConfig survives serialization.""" - fc = FrontendConfig( - name="SamplerV2", - sdk="qiskit_runtime", - sdk_version="0.25.0", - config={"resilience_level": 1}, - ) - restored = FrontendConfig.from_dict(fc.to_dict()) - - assert restored.name == "SamplerV2" - assert restored.config["resilience_level"] == 1 - - -class TestDeviceSnapshot: - """Tests for DeviceSnapshot dataclass.""" - - def test_connectivity_serialization(self): - """Connectivity tuples convert to lists and back.""" - snap = DeviceSnapshot( - captured_at="2024-01-01T00:00:00Z", - backend_name="test", - backend_type="hardware", - provider="test", - connectivity=[(0, 1), (1, 2), (2, 3)], - ) - d = snap.to_dict() - restored = DeviceSnapshot.from_dict(d) - - assert d["connectivity"] == [[0, 1], [1, 2], [2, 3]] - assert restored.connectivity == [(0, 1), (1, 2), (2, 3)] - - def test_get_calibration_summary(self, calibration_factory): - """get_calibration_summary returns compact metrics.""" - cal = calibration_factory(num_qubits=3) - snap = DeviceSnapshot( - captured_at="2024-01-01T00:00:00Z", - backend_name="test", - backend_type="hardware", - provider="test", - calibration=cal, - ) - summary = snap.get_calibration_summary() - - assert "median_t1_us" in summary - assert "median_2q_error" in summary - - def test_get_calibration_summary_none_without_calibration(self): - """get_calibration_summary returns None if no calibration.""" - snap = DeviceSnapshot( - captured_at="2024-01-01T00:00:00Z", - backend_name="test", - backend_type="simulator", - provider="test", - ) - assert snap.get_calibration_summary() is None - - -class TestProgramArtifact: - """Tests for ProgramArtifact dataclass.""" - - def test_round_trip(self): - """ProgramArtifact survives serialization.""" - ref = ArtifactRef( - kind="qiskit.qpy.circuits", - digest="sha256:" + "a" * 64, - media_type="application/x-qpy", - role="program", - ) - pa = ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="qpy", - name="bell_state", - index=0, - ) - restored = ProgramArtifact.from_dict(pa.to_dict()) - - assert restored.format == "qpy" - assert restored.name == "bell_state" - - -class TestProgramSnapshot: - """Tests for ProgramSnapshot dataclass.""" - - def test_empty_snapshot(self): - """Empty snapshot serializes with schema and empty lists.""" - snap = ProgramSnapshot() - d = snap.to_dict() - - assert d["schema"] == "devqubit.program_snapshot/0.1" - # Empty lists are included in serialization - assert d["logical"] == [] - assert d["physical"] == [] - - def test_non_empty_snapshot(self): - """Non-empty snapshot includes artifacts.""" - ref = ArtifactRef( - kind="qiskit.qpy.circuits", - digest="sha256:" + "a" * 64, - media_type="application/x-qpy", - role="program", - ) - pa = ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="qpy", - name="bell_state", - index=0, - ) - snap = ProgramSnapshot(logical=[pa]) - d = snap.to_dict() - - assert len(d["logical"]) == 1 - assert d["logical"][0]["name"] == "bell_state" - - -class TestTranspilationInfo: - """Tests for TranspilationInfo dataclass.""" - - def test_mode_enum_serialization(self): - """TranspilationMode enum serializes to string.""" - ti = TranspilationInfo(mode=TranspilationMode.MANAGED) - d = ti.to_dict() - - assert d["mode"] == "managed" - - def test_optimization_level_serialization(self): - """TranspilationInfo serializes optimization_level.""" - ti = TranspilationInfo( - mode=TranspilationMode.AUTO, - optimization_level=2, - ) - d = ti.to_dict() - - assert d["mode"] == "auto" - assert d["optimization_level"] == 2 - - def test_round_trip(self): - """TranspilationInfo survives serialization round-trip.""" - ti = TranspilationInfo( - mode=TranspilationMode.AUTO, - optimization_level=3, - layout_method="sabre", - routing_method="stochastic", - ) - d = ti.to_dict() - restored = TranspilationInfo.from_dict(d) - - assert restored.mode == TranspilationMode.AUTO - assert restored.optimization_level == 3 - assert restored.layout_method == "sabre" - assert restored.routing_method == "stochastic" - - -class TestExecutionSnapshot: - """Tests for ExecutionSnapshot dataclass.""" - - def test_round_trip_with_transpilation(self): - """ExecutionSnapshot with transpilation survives serialization.""" - snap = ExecutionSnapshot( - submitted_at="2024-01-01T10:00:00Z", - completed_at="2024-01-01T10:05:00Z", - shots=1000, - job_ids=["job_123"], - transpilation=TranspilationInfo( - mode=TranspilationMode.AUTO, - optimization_level=1, - ), - sdk="qiskit", - ) - restored = ExecutionSnapshot.from_dict(snap.to_dict()) - - assert restored.shots == 1000 - assert restored.transpilation.optimization_level == 1 - - def test_minimal_snapshot(self): - """ExecutionSnapshot with only required fields.""" - snap = ExecutionSnapshot(submitted_at="2024-01-01T10:00:00Z") - d = snap.to_dict() - - assert d["submitted_at"] == "2024-01-01T10:00:00Z" - assert "shots" not in d - - -class TestNormalizedCounts: - """Tests for NormalizedCounts dataclass.""" - - def test_bell_state_counts(self, bell_state_counts): - """NormalizedCounts handles Bell state results.""" - nc = NormalizedCounts( - circuit_index=0, - counts=bell_state_counts, - shots=1000, - name="bell", - ) - d = nc.to_dict() - - assert d["counts"]["00"] == 500 - assert d["shots"] == 1000 - - -class TestResultSnapshot: - """Tests for ResultSnapshot dataclass.""" - - def test_counts_result(self, bell_state_counts): - """ResultSnapshot with counts.""" - snap = ResultSnapshot( - result_type=ResultType.COUNTS, - counts=[NormalizedCounts(circuit_index=0, counts=bell_state_counts)], - num_experiments=1, - success=True, - ) - d = snap.to_dict() - - assert d["result_type"] == "counts" - assert d["success"] is True - - def test_failed_result(self): - """ResultSnapshot captures failure info.""" - snap = ResultSnapshot( - result_type=ResultType.COUNTS, - success=False, - error_message="Backend timeout", - ) - d = snap.to_dict() - - assert d["success"] is False - assert d["error_message"] == "Backend timeout" - - def test_from_dict_defaults_result_type(self): - """from_dict defaults result_type to 'counts'.""" - snap = ResultSnapshot.from_dict({}) - assert snap.result_type == "counts" - - -class TestValidationResult: - """Tests for ValidationResult dataclass.""" - - def test_valid_result_is_truthy(self): - """Valid result evaluates to True.""" - result = ValidationResult(valid=True) - assert result - assert result.valid - assert result.errors == [] - - def test_invalid_result_is_falsy(self): - """Invalid result evaluates to False.""" - result = ValidationResult(valid=False, errors=["error1", "error2"]) - assert not result - assert not result.valid - assert len(result.errors) == 2 - - def test_warnings_preserved(self): - """Warnings are preserved in result.""" - result = ValidationResult( - valid=True, - warnings=["module not available"], - ) - assert result.valid - assert "module not available" in result.warnings - - -class TestExecutionEnvelope: - """Tests for ExecutionEnvelope dataclass.""" - - def test_to_dict_includes_schema(self): - """to_dict always includes schema version.""" - env = ExecutionEnvelope() - d = env.to_dict() - - assert d["schema"] == "devqubit.envelope/0.1" - - def test_round_trip_complete_envelope(self, calibration_factory): - """Complete envelope survives serialization.""" - env = ExecutionEnvelope( - device=DeviceSnapshot( - captured_at="2024-01-01T00:00:00Z", - backend_name="ibm_brisbane", - backend_type="hardware", - provider="ibm_quantum", - calibration=calibration_factory(num_qubits=3), - ), - execution=ExecutionSnapshot( - submitted_at="2024-01-01T00:00:00Z", - shots=1000, - ), - result=ResultSnapshot(result_type=ResultType.COUNTS, success=True), - adapter="qiskit", - envelope_id="env_123", - created_at="2024-01-01T00:00:00Z", - ) - restored = ExecutionEnvelope.from_dict(env.to_dict()) - - assert restored.device.backend_name == "ibm_brisbane" - assert restored.adapter == "qiskit" - - def test_validate_missing_snapshots(self): - """validate() reports missing snapshots.""" - env = ExecutionEnvelope() - warnings = env.validate() - - assert "Missing device snapshot" in warnings - assert "Missing program snapshot" in warnings - assert "Missing execution snapshot" in warnings - assert "Missing result snapshot" in warnings - - def test_validate_missing_backend_name(self): - """validate() reports missing backend_name.""" - env = ExecutionEnvelope( - device=DeviceSnapshot( - captured_at="2024-01-01T00:00:00Z", - backend_name="", - backend_type="simulator", - provider="test", - ), - ) - warnings = env.validate() - - assert "Device snapshot missing backend_name" in warnings - - def test_validate_failed_result_without_error(self): - """validate() warns on failed result without error_message.""" - env = ExecutionEnvelope( - result=ResultSnapshot(result_type=ResultType.COUNTS, success=False), - ) - warnings = env.validate() - - assert "Failed result missing error_message" in warnings - - def test_validate_schema_returns_validation_result(self): - """validate_schema returns ValidationResult object.""" - env = ExecutionEnvelope() - - # Mock validation module returning no errors - mock_module = type( - "MockModule", (), {"validate_envelope": lambda *a, **k: []} - )() - - with patch.dict( - "sys.modules", - {"devqubit_engine.schema.validation": mock_module}, - ): - result = env.validate_schema() - - assert isinstance(result, ValidationResult) - assert result.valid - assert result.errors == [] - - def test_validate_schema_returns_errors_in_result(self): - """validate_schema returns errors in ValidationResult.""" - env = ExecutionEnvelope() - - # Mock validation module returning errors - mock_module = type( - "MockModule", - (), - {"validate_envelope": lambda *a, **k: ["error1", "error2"]}, - )() - - with patch.dict( - "sys.modules", - {"devqubit_engine.schema.validation": mock_module}, - ): - result = env.validate_schema() - - assert isinstance(result, ValidationResult) - assert not result.valid - assert len(result.errors) == 2 - - def test_validate_schema_handles_exception(self): - """validate_schema catches exceptions and returns invalid result.""" - env = ExecutionEnvelope() - - def raise_error(*args, **kwargs): - raise ValueError("Schema validation error") - - mock_module = type("MockModule", (), {"validate_envelope": raise_error})() - - with patch.dict( - "sys.modules", - {"devqubit_engine.schema.validation": mock_module}, - ): - result = env.validate_schema() - - assert isinstance(result, ValidationResult) - assert not result.valid - assert len(result.errors) == 1 - assert isinstance(result.errors[0], ValueError) - - def test_validate_schema_handles_import_error(self): - """validate_schema returns valid with warning on ImportError.""" - env = ExecutionEnvelope() - - # Set module to None to trigger ImportError - with patch.dict( - "sys.modules", - {"devqubit_engine.schema.validation": None}, - ): - result = env.validate_schema() - - assert isinstance(result, ValidationResult) - assert result.valid # Valid because we can't validate - assert len(result.warnings) == 1 - assert "not available" in result.warnings[0] diff --git a/packages/devqubit-engine/tests/test_uec.py b/packages/devqubit-engine/tests/test_uec.py new file mode 100644 index 0000000..9b5dd15 --- /dev/null +++ b/packages/devqubit-engine/tests/test_uec.py @@ -0,0 +1,440 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +"""Tests for devqubit UEC snapshot types.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from devqubit_engine.uec.calibration import ( + DeviceCalibration, + GateCalibration, + QubitCalibration, +) +from devqubit_engine.uec.device import DeviceSnapshot +from devqubit_engine.uec.envelope import ExecutionEnvelope +from devqubit_engine.uec.program import ProgramArtifact +from devqubit_engine.uec.result import ( + QuasiProbability, + ResultError, + ResultItem, + ResultSnapshot, +) +from devqubit_engine.uec.types import ( + ArtifactRef, + ProgramRole, + ValidationResult, +) + + +class TestGateCalibration: + """Tests for GateCalibration dataclass.""" + + def test_qubits_tuple_to_list_conversion(self): + """to_dict converts qubits tuple to list for JSON.""" + gc = GateCalibration(gate="cx", qubits=(0, 1), error=0.005) + d = gc.to_dict() + + assert d["qubits"] == [0, 1] + assert isinstance(d["qubits"], list) + + def test_round_trip_preserves_tuple(self): + """from_dict restores qubits as tuple.""" + gc = GateCalibration(gate="cx", qubits=(0, 1), error=0.01, duration_ns=300.0) + restored = GateCalibration.from_dict(gc.to_dict()) + + assert restored.qubits == (0, 1) + assert isinstance(restored.qubits, tuple) + + def test_is_two_qubit(self): + """is_two_qubit correctly identifies multi-qubit gates.""" + single = GateCalibration(gate="x", qubits=(0,)) + two = GateCalibration(gate="cx", qubits=(0, 1)) + three = GateCalibration(gate="ccx", qubits=(0, 1, 2)) + + assert not single.is_two_qubit + assert two.is_two_qubit + assert three.is_two_qubit # >= 2 qubits + + +class TestDeviceCalibration: + """Tests for DeviceCalibration dataclass.""" + + def test_compute_medians(self): + """compute_medians calculates correct median values.""" + cal = DeviceCalibration( + qubits=[ + QubitCalibration(qubit=0, t1_us=100.0, t2_us=80.0, readout_error=0.01), + QubitCalibration(qubit=1, t1_us=120.0, t2_us=90.0, readout_error=0.02), + QubitCalibration(qubit=2, t1_us=110.0, t2_us=85.0, readout_error=0.015), + ], + gates=[ + GateCalibration(gate="cx", qubits=(0, 1), error=0.01), + GateCalibration(gate="cx", qubits=(1, 2), error=0.02), + ], + ) + cal.compute_medians() + + assert cal.median_t1_us == 110.0 + assert cal.median_t2_us == 85.0 + assert cal.median_readout_error == 0.015 + assert cal.median_2q_error == 0.015 + + def test_compute_medians_ignores_none(self): + """compute_medians skips qubits with missing values.""" + cal = DeviceCalibration( + qubits=[ + QubitCalibration(qubit=0, t1_us=100.0), + QubitCalibration(qubit=1, t1_us=None), + QubitCalibration(qubit=2, t1_us=120.0), + ], + ) + cal.compute_medians() + + assert cal.median_t1_us == 110.0 + + def test_to_dict_auto_computes_medians(self): + """to_dict triggers median computation if needed.""" + cal = DeviceCalibration( + qubits=[QubitCalibration(qubit=0, t1_us=100.0)], + ) + d = cal.to_dict() + + assert "median_t1_us" in d + assert d["median_t1_us"] == 100.0 + + def test_calibration_factory_fixture(self, calibration_factory): + """Fixture creates valid calibration with computed medians.""" + cal = calibration_factory(num_qubits=5) + + assert len(cal.qubits) == 5 + assert len(cal.gates) == 4 + assert cal.median_t1_us is not None + + +class TestDeviceSnapshot: + """Tests for DeviceSnapshot dataclass.""" + + def test_connectivity_serialization(self): + """Connectivity tuples convert to lists and back.""" + snap = DeviceSnapshot( + captured_at="2024-01-01T00:00:00Z", + backend_name="test", + backend_type="hardware", + provider="ibm_quantum", + connectivity=[(0, 1), (1, 2), (2, 3)], + ) + d = snap.to_dict() + restored = DeviceSnapshot.from_dict(d) + + assert d["connectivity"] == [[0, 1], [1, 2], [2, 3]] + assert restored.connectivity == [(0, 1), (1, 2), (2, 3)] + + def test_get_calibration_summary(self, calibration_factory): + """get_calibration_summary returns compact metrics.""" + cal = calibration_factory(num_qubits=3) + snap = DeviceSnapshot( + captured_at="2024-01-01T00:00:00Z", + backend_name="test", + backend_type="hardware", + provider="ibm_quantum", + calibration=cal, + ) + summary = snap.get_calibration_summary() + + assert "median_t1_us" in summary + assert "median_2q_error" in summary + + def test_get_calibration_summary_none_without_calibration(self): + """get_calibration_summary returns None if no calibration.""" + snap = DeviceSnapshot( + captured_at="2024-01-01T00:00:00Z", + backend_name="test", + backend_type="simulator", + provider="local", + ) + assert snap.get_calibration_summary() is None + + +class TestProgramArtifact: + """Tests for ProgramArtifact dataclass.""" + + def test_round_trip(self): + """ProgramArtifact survives serialization.""" + ref = ArtifactRef( + kind="qiskit.qpy.circuits", + digest="sha256:" + "a" * 64, + media_type="application/vnd.qiskit.qpy", + role="program", + ) + artifact = ProgramArtifact( + ref=ref, + role=ProgramRole.LOGICAL, + format="qpy", + name="bell_circuit", + index=0, + ) + restored = ProgramArtifact.from_dict(artifact.to_dict()) + + assert restored.role == ProgramRole.LOGICAL + assert restored.format == "qpy" + assert restored.name == "bell_circuit" + + +class TestResultItem: + """Tests for per-circuit result items.""" + + def test_bell_state_counts_item(self, bell_state_counts, qiskit_counts_format): + """Create result item from Bell state measurement.""" + item = ResultItem.from_counts( + item_index=0, + counts=bell_state_counts, + shots=1000, + format_info=qiskit_counts_format, + ) + + assert item.success is True + assert item.item_index == 0 + assert item.counts["counts"]["00"] == 500 + assert item.counts["shots"] == 1000 + assert item.counts["format"]["source_sdk"] == "qiskit" + + def test_failed_circuit_in_batch(self): + """Individual circuit failure in a batch.""" + item = ResultItem( + item_index=2, + success=False, + error_message="Circuit depth 150 exceeds backend limit 100", + ) + + assert item.success is False + assert "depth" in item.error_message.lower() + + +class TestQuasiProbability: + """Tests for IBM Runtime quasi-distributions.""" + + def test_from_integer_keys(self): + """Convert integer keys to bitstrings (common IBM format).""" + qp = QuasiProbability.from_quasi_dist({0: 0.48, 3: 0.52}, num_clbits=2) + + assert "00" in qp.distribution + assert "11" in qp.distribution + assert abs(qp.sum_probs - 1.0) < 0.01 + + def test_negative_probabilities_from_mitigation(self): + """Error mitigation can produce negative quasi-probabilities.""" + qp = QuasiProbability( + distribution={"00": 0.52, "01": -0.02, "10": -0.01, "11": 0.51}, + ) + + assert qp.distribution["01"] < 0 + assert qp.distribution["10"] < 0 + + +class TestResultError: + """Tests for structured error capture.""" + + def test_from_backend_timeout(self): + """Capture timeout exception with retryable flag.""" + try: + raise TimeoutError("Backend did not respond within 300s") + except TimeoutError as e: + error = ResultError.from_exception(e) + + assert error.type == "TimeoutError" + assert "300s" in error.message + assert error.retryable is True + assert len(error.stack_hash) == 16 + + def test_from_validation_error(self): + """Non-retryable validation error.""" + try: + raise ValueError("Invalid parameter: shots must be positive") + except ValueError as e: + error = ResultError.from_exception(e) + + assert error.type == "ValueError" + assert error.retryable is False + + +class TestResultSnapshot: + """Tests for complete result snapshots.""" + + def test_successful_bell_state_execution( + self, bell_state_counts, qiskit_counts_format + ): + """Happy path: successful Bell state measurement.""" + item = ResultItem.from_counts(0, bell_state_counts, 1000, qiskit_counts_format) + snap = ResultSnapshot.create_success(items=[item]) + + assert snap.success is True + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.error is None + + def test_failed_execution_preserves_exception_info(self): + """Failure path: exception captured with full context.""" + try: + raise RuntimeError("Job cancelled: insufficient credits") + except RuntimeError as e: + snap = ResultSnapshot.create_failed(exception=e) + + assert snap.success is False + assert snap.status == "failed" + assert snap.error.type == "RuntimeError" + assert "credits" in snap.error.message + + def test_partial_batch_execution(self, bell_state_counts, qiskit_counts_format): + """Partial success: some circuits in batch failed.""" + items = [ + ResultItem.from_counts(0, bell_state_counts, 1000, qiskit_counts_format), + ResultItem(item_index=1, success=False, error_message="Circuit 1 too deep"), + ResultItem.from_counts(2, {"0": 1000}, 1000, qiskit_counts_format), + ] + snap = ResultSnapshot.create_partial(items=items) + + assert snap.success is False + assert snap.status == "partial" + assert snap.items[0].success is True + assert snap.items[1].success is False + assert snap.items[2].success is True + + def test_invalid_status_rejected(self): + """Invalid status values are rejected at construction.""" + with pytest.raises(ValueError, match="status must be one of"): + ResultSnapshot(success=True, status="running") + + def test_round_trip_serialization(self, bell_state_counts, qiskit_counts_format): + """Result snapshot survives JSON round-trip.""" + item = ResultItem.from_counts(0, bell_state_counts, 1000, qiskit_counts_format) + original = ResultSnapshot.create_success( + items=[item], + metadata={"backend": "ibm_brisbane", "job_id": "abc123"}, + ) + + restored = ResultSnapshot.from_dict(original.to_dict()) + + assert restored.success == original.success + assert restored.status == original.status + assert restored.items[0].counts["counts"]["00"] == 500 + assert restored.metadata["backend"] == "ibm_brisbane" + + +class TestExecutionEnvelope: + """Tests for complete execution records.""" + + def test_create_minimal_envelope(self, minimal_producer): + """Minimal valid envelope with required fields only.""" + result = ResultSnapshot(success=True, status="completed", items=[]) + env = ExecutionEnvelope.create(producer=minimal_producer, result=result) + + assert len(env.envelope_id) == 26 + assert env.created_at is not None + assert env.schema_version == "devqubit.envelope/1.0" + assert env.producer.adapter == "devqubit-test" + + def test_create_generates_failed_result_if_none(self, minimal_producer): + """Factory creates failed result if None provided.""" + env = ExecutionEnvelope.create(producer=minimal_producer, result=None) + + assert env.result.success is False + assert env.result.status == "failed" + + def test_validate_warns_on_missing_snapshots(self, minimal_producer): + """Validation warns about missing optional snapshots.""" + env = ExecutionEnvelope.create( + producer=minimal_producer, + result=ResultSnapshot(success=True, status="completed", items=[]), + ) + warnings = env.validate() + + assert "Missing device snapshot" in warnings + assert "Missing program snapshot" in warnings + assert "Missing execution snapshot" in warnings + + def test_validate_warns_on_failed_without_error(self, minimal_producer): + """Validation warns when failed result lacks error details.""" + env = ExecutionEnvelope.create( + producer=minimal_producer, + result=ResultSnapshot(success=False, status="failed", items=[]), + ) + warnings = env.validate() + + assert "Failed result missing error details" in warnings + + def test_validate_schema_returns_validation_result(self, minimal_producer): + """validate_schema returns ValidationResult object.""" + env = ExecutionEnvelope.create( + producer=minimal_producer, + result=ResultSnapshot(success=True, status="completed", items=[]), + ) + + mock_module = type( + "MockModule", + (), + {"validate_envelope": lambda *a, **k: []}, + )() + + with patch.dict( + "sys.modules", + {"devqubit_engine.schema.validation": mock_module}, + ): + result = env.validate_schema() + + assert isinstance(result, ValidationResult) + assert result.valid + assert result.errors == [] + + +class TestArtifactRef: + """Tests for content-addressed artifact references.""" + + def test_invalid_digest_rejected(self): + """Malformed digest is rejected.""" + with pytest.raises(ValueError, match="Invalid digest format"): + ArtifactRef( + kind="test", + digest="md5:abc123", + media_type="text/plain", + role="test", + ) + + def test_short_kind_rejected(self): + """Kind must be at least 3 characters.""" + with pytest.raises(ValueError, match="at least 3 characters"): + ArtifactRef( + kind="ab", + digest="sha256:" + "a" * 64, + media_type="text/plain", + role="test", + ) + + +class TestValidationResult: + """Tests for validation result handling.""" + + def test_valid_result_is_truthy(self): + """Valid result evaluates to True in boolean context.""" + result = ValidationResult(valid=True) + assert result + assert result.ok + assert result.error_count == 0 + + def test_invalid_result_is_falsy(self): + """Invalid result evaluates to False.""" + result = ValidationResult(valid=False, errors=["Missing field", "Invalid type"]) + assert not result + assert not result.ok + assert result.error_count == 2 + + def test_warnings_preserved(self): + """Warnings are preserved in result.""" + result = ValidationResult( + valid=True, + warnings=["Schema validation module not available"], + ) + assert result.valid + assert "not available" in result.warnings[0] diff --git a/packages/devqubit-pennylane/src/devqubit_pennylane/adapter.py b/packages/devqubit-pennylane/src/devqubit_pennylane/adapter.py index fc18c02..ed40815 100644 --- a/packages/devqubit-pennylane/src/devqubit_pennylane/adapter.py +++ b/packages/devqubit-pennylane/src/devqubit_pennylane/adapter.py @@ -48,12 +48,14 @@ import logging import traceback import types +import uuid from typing import Any from devqubit_engine.core.run import Run from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.envelope import ExecutionEnvelope from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.producer import ProducerInfo from devqubit_engine.uec.program import ( ProgramArtifact, ProgramSnapshot, @@ -75,6 +77,7 @@ from devqubit_pennylane.utils import ( collect_sdk_versions, extract_shots_info, + get_adapter_version, get_device_name, is_pennylane_device, ) @@ -576,9 +579,17 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: # Log structure and build ProgramSnapshot program_artifacts: list[ProgramArtifact] = [] if should_log_structure and tapes and circuit_hash not in logged_hashes: + # Get physical provider from device snapshot + physical_provider = ( + self._devqubit_device_snapshot.provider + if self._devqubit_device_snapshot + else "local" + ) + tracker.set_tag("backend_name", backend_name) - tracker.set_tag("provider", "pennylane") - tracker.set_tag("adapter", "pennylane") + tracker.set_tag("provider", physical_provider) # Physical provider + tracker.set_tag("sdk", "pennylane") # SDK frontend + tracker.set_tag("adapter", "devqubit-pennylane") program_artifacts = _log_tapes(tracker, tapes, circuit_hash) @@ -588,7 +599,8 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: tracker.record["backend"] = { "name": backend_name, "type": self.__class__.__name__, - "provider": "pennylane", + "provider": physical_provider, # Physical provider + "sdk": "pennylane", # SDK frontend } self._devqubit_logged_execution_count += 1 @@ -629,6 +641,7 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: result = None execution_error: dict[str, Any] | None = None execution_succeeded = True + original_exception: BaseException | None = None # Save for re-raise try: result = getattr(self, f"_devqubit_original_{method_name}")( @@ -636,6 +649,7 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: ) except Exception as e: execution_succeeded = False + original_exception = e # Save original exception execution_error = { "type": type(e).__name__, "message": str(e), @@ -697,16 +711,34 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: "success": execution_succeeded, } - # Create and finalize ExecutionEnvelope + # Create and finalize ExecutionEnvelope (UEC 1.0) if self._devqubit_device_snapshot is not None: + # Create ProducerInfo for UEC 1.0 + sdk_versions = collect_sdk_versions() + producer = ProducerInfo.create( + adapter="devqubit-pennylane", + adapter_version=get_adapter_version(), + sdk="pennylane", + sdk_version=sdk_versions.get("pennylane", "unknown"), + frontends=["pennylane"], + ) + + # Create pending result (will be updated when finalized) + pending_result = ResultSnapshot( + success=False, + status="failed", # Will be updated by _finalize_envelope_with_result + items=[], + metadata={"state": "pending"}, + ) + self._devqubit_envelope = ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="pennylane", + envelope_id=uuid.uuid4().hex[:26], created_at=utc_now_iso(), + producer=producer, + result=pending_result, device=self._devqubit_device_snapshot, program=self._devqubit_program_snapshot, execution=self._devqubit_execution_snapshot, - result=None, # Will be filled by _finalize_envelope_with_result ) try: _finalize_envelope_with_result( @@ -738,12 +770,9 @@ def wrapped(self: Any, circuits: Any, *args: Any, **kwargs: Any) -> Any: "last_execution_at": utc_now_iso(), } - # Re-raise execution error after logging - if not execution_succeeded and execution_error: - # Reconstruct the original exception type if possible - exc_type = execution_error["type"] - exc_msg = execution_error["message"] - raise RuntimeError(f"{exc_type}: {exc_msg}") from None + # Re-raise execution error after logging (preserve original exception type) + if not execution_succeeded and original_exception is not None: + raise original_exception return result @@ -806,10 +835,15 @@ def describe_executor(self, device: Any) -> dict[str, Any]: dict Device description with multi-layer stack info. """ + # Detect physical execution provider + backend_info = resolve_pennylane_backend(device) + physical_provider = backend_info["provider"] if backend_info else "local" + desc: dict[str, Any] = { "name": get_device_name(device), "type": device.__class__.__name__, - "provider": "pennylane", + "provider": physical_provider, # Physical provider + "sdk": "pennylane", # SDK frontend } # Add wire info @@ -824,10 +858,8 @@ def describe_executor(self, device: Any) -> dict[str, Any]: shots_info = extract_shots_info(device) desc["shots_info"] = shots_info.to_dict() - # Detect execution provider - backend_info = resolve_pennylane_backend(device) - if backend_info and backend_info["provider"] != "pennylane": - desc["execution_provider"] = backend_info["provider"] + # Add backend-specific info + if backend_info: desc["backend_type"] = backend_info["backend_type"] if backend_info["backend_id"]: desc["backend_id"] = backend_info["backend_id"] diff --git a/packages/devqubit-pennylane/src/devqubit_pennylane/results.py b/packages/devqubit-pennylane/src/devqubit_pennylane/results.py index 940496b..28a4851 100644 --- a/packages/devqubit-pennylane/src/devqubit_pennylane/results.py +++ b/packages/devqubit-pennylane/src/devqubit_pennylane/results.py @@ -11,20 +11,32 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import Any import numpy as np from devqubit_engine.uec.result import ( - NormalizedCounts, + CountsFormat, NormalizedExpectation, + ResultError, + ResultItem, ResultSnapshot, ) -from devqubit_engine.uec.types import ResultType logger = logging.getLogger(__name__) +# Internal dataclass for intermediate counts extraction +@dataclass +class _CountsData: + """Internal representation for extracted counts.""" + + circuit_index: int + counts: dict[str, int] + shots: int | None + + def _result_type_for_tape(tape: Any) -> str: """ Determine a single-tape result type based on its first measurement. @@ -104,41 +116,6 @@ def extract_result_type(tapes: list[Any]) -> str: return "mixed" -def _map_result_type_to_enum(result_type: str | None) -> ResultType: - """ - Map PennyLane result type string to UEC ResultType enum. - - Parameters - ---------- - result_type : str or None - PennyLane result type string. - - Returns - ------- - ResultType - UEC result type enum. - """ - if result_type is None: - return ResultType.OTHER - - rt_lower = result_type.lower() - - if "expectation" in rt_lower or "expval" in rt_lower: - return ResultType.EXPECTATION - elif "sample" in rt_lower: - return ResultType.SAMPLES - elif "counts" in rt_lower: - return ResultType.COUNTS - elif "probability" in rt_lower or "probs" in rt_lower: - return ResultType.QUASI_DIST - elif "state" in rt_lower: - return ResultType.STATEVECTOR - elif "variance" in rt_lower or "var" in rt_lower: - return ResultType.EXPECTATION # Variance is expectation-like - else: - return ResultType.OTHER - - def _to_numpy(arr: Any) -> np.ndarray | None: """ Safely convert array-like to numpy array. @@ -304,7 +281,7 @@ def _extract_expectation_values( def _extract_sample_counts( results: Any, num_circuits: int = 1, -) -> list[NormalizedCounts]: +) -> list[_CountsData]: """ Extract sample counts from PennyLane results. @@ -320,7 +297,7 @@ def _extract_sample_counts( Returns ------- - list of NormalizedCounts + list of _CountsData Normalized counts. """ if results is None: @@ -328,14 +305,14 @@ def _extract_sample_counts( from collections import Counter - counts_list: list[NormalizedCounts] = [] + counts_list: list[_CountsData] = [] try: # Case 1: Already counts-like (dict) if isinstance(results, dict): counts_dict = {str(k): int(v) for k, v in results.items()} counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=0, counts=counts_dict, shots=sum(counts_dict.values()), @@ -352,7 +329,7 @@ def _extract_sample_counts( counter = Counter(bitstrings) counts_dict = dict(counter) counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=0, counts=counts_dict, shots=len(bitstrings), @@ -365,7 +342,7 @@ def _extract_sample_counts( counter = Counter(bitstrings) counts_dict = dict(counter) counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=0, counts=counts_dict, shots=len(bitstrings), @@ -380,7 +357,7 @@ def _extract_sample_counts( # Already counts counts_dict = {str(k): int(v) for k, v in res.items()} counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=i, counts=counts_dict, shots=sum(counts_dict.values()), @@ -402,7 +379,7 @@ def _extract_sample_counts( counter = Counter(bitstrings) counts_dict = dict(counter) counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=i, counts=counts_dict, shots=len(bitstrings), @@ -418,7 +395,7 @@ def _extract_sample_counts( def _extract_probabilities( results: Any, num_circuits: int = 1, -) -> list[NormalizedCounts]: +) -> list[_CountsData]: """ Extract probabilities from PennyLane results as pseudo-counts. @@ -433,13 +410,13 @@ def _extract_probabilities( Returns ------- - list of NormalizedCounts + list of _CountsData Normalized probabilities as counts (values sum to ~1). """ if results is None: return [] - counts_list: list[NormalizedCounts] = [] + counts_list: list[_CountsData] = [] try: arr = _to_numpy(results) @@ -455,7 +432,7 @@ def _extract_probabilities( } if counts_dict: counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=0, counts=counts_dict, shots=None, # Probabilities don't have shots @@ -477,7 +454,7 @@ def _extract_probabilities( } if counts_dict: counts_list.append( - NormalizedCounts( + _CountsData( circuit_index=i, counts=counts_dict, shots=None, @@ -503,6 +480,8 @@ def build_result_snapshot( """ Build a ResultSnapshot from PennyLane execution results. + Uses UEC 1.0 structure with items[] for per-circuit results. + Parameters ---------- results : Any @@ -523,33 +502,80 @@ def build_result_snapshot( Returns ------- ResultSnapshot - Structured result snapshot. - - Examples - -------- - >>> snapshot = build_result_snapshot( - ... [0.5, -0.3], - ... result_type="Expectation", - ... num_circuits=2, - ... ) - >>> snapshot.result_type - ResultType.EXPECTATION + Structured result snapshot following UEC 1.0. """ - uec_result_type = _map_result_type_to_enum(result_type) + # Determine status + status = "completed" if success else "failed" - # Extract normalized results based on type - counts: list[NormalizedCounts] = [] - expectations: list[NormalizedExpectation] = [] + # Build error object if execution failed + error: ResultError | None = None + if not success and error_info: + error = ResultError( + type=error_info.get("type", "UnknownError"), + message=error_info.get("message", "Unknown error"), + ) + + # Build items list (UEC 1.0) + items: list[ResultItem] = [] if success and results is not None: try: - if uec_result_type == ResultType.EXPECTATION: + rt_lower = (result_type or "").lower() + + # Handle expectation values + if "expectation" in rt_lower or "expval" in rt_lower or "var" in rt_lower: expectations = _extract_expectation_values(results, num_circuits) - elif uec_result_type in (ResultType.COUNTS, ResultType.SAMPLES): - counts = _extract_sample_counts(results, num_circuits) - elif uec_result_type == ResultType.QUASI_DIST: - counts = _extract_probabilities(results, num_circuits) - # For STATEVECTOR and OTHER, we just store raw results + for exp in expectations: + items.append( + ResultItem( + item_index=exp.circuit_index, + success=True, + expectation=exp, + ) + ) + + # Handle counts/samples + elif "counts" in rt_lower or "sample" in rt_lower: + counts_list = _extract_sample_counts(results, num_circuits) + # PennyLane counts format + counts_format = CountsFormat( + source_sdk="pennylane", + source_key_format="pennylane_bitstring", + bit_order="cbit0_right", # Canonical UEC format + transformed=False, + ) + for cd in counts_list: + items.append( + ResultItem( + item_index=cd.circuit_index, + success=True, + counts={ + "counts": cd.counts, + "shots": cd.shots, + "format": counts_format.to_dict(), + }, + ) + ) + + # Handle probabilities + elif "probability" in rt_lower or "probs" in rt_lower: + probs_list = _extract_probabilities(results, num_circuits) + for cd in probs_list: + items.append( + ResultItem( + item_index=cd.circuit_index, + success=True, + counts={ + "counts": cd.counts, + "shots": None, # Probabilities don't have shots + "format": { + "source_sdk": "pennylane", + "source_key_format": "probability_distribution", + }, + }, + ) + ) + except Exception as e: logger.debug("Failed to extract normalized results: %s", e) @@ -557,16 +583,14 @@ def build_result_snapshot( metadata: dict[str, Any] = { "backend_name": backend_name, "pennylane_result_type": result_type, + "num_circuits": num_circuits, } - if error_info: - metadata["error"] = error_info return ResultSnapshot( - result_type=uec_result_type, - raw_result_ref=raw_result_ref, - counts=counts if counts else None, - expectations=expectations if expectations else None, - num_experiments=num_circuits, success=success, + status=status, + items=items, + error=error, + raw_result_ref=raw_result_ref, metadata=metadata, ) diff --git a/packages/devqubit-pennylane/src/devqubit_pennylane/snapshot.py b/packages/devqubit-pennylane/src/devqubit_pennylane/snapshot.py index 81d30c8..b55ea50 100644 --- a/packages/devqubit-pennylane/src/devqubit_pennylane/snapshot.py +++ b/packages/devqubit-pennylane/src/devqubit_pennylane/snapshot.py @@ -45,7 +45,10 @@ def _detect_execution_provider(device: Any) -> str: """ - Detect the execution provider from device name/type. + Detect the PHYSICAL execution provider from device name/type. + + Per UEC, provider should be the physical execution platform, + not the SDK name. Parameters ---------- @@ -55,40 +58,88 @@ def _detect_execution_provider(device: Any) -> str: Returns ------- str - Provider identifier: 'braket', 'qiskit', 'pennylane', or 'unknown'. + Physical provider identifier: 'local', 'aws_braket', 'ibm_quantum'. """ device_name = get_device_name(device).lower() short_name = getattr(device, "short_name", "").lower() module = getattr(device, "__module__", "").lower() - # Check for Braket plugin + # Check for Braket plugin -> physical provider is AWS or local if ( device_name.startswith("braket.") or short_name.startswith("braket.") or "braket" in module ): - return "braket" + # Check if it's a local simulator + if "local" in device_name or "localsimulator" in module: + return "local" + return "aws_braket" - # Check for Qiskit plugin + # Check for Qiskit plugin -> physical provider depends on backend if ( device_name.startswith("qiskit.") or short_name.startswith("qiskit.") or "qiskit" in module ): - return "qiskit" + # Check backend for IBM quantum vs local + backend = getattr(device, "backend", None) or getattr(device, "_backend", None) + if backend: + backend_name = str(getattr(backend, "name", "")).lower() + if "ibm_" in backend_name or "ibmq" in backend_name: + return "ibm_quantum" + if "aer" in backend_name or "fake" in backend_name: + return "local" + return "ibm_quantum" # Default for Qiskit plugin - # Check for PennyLane native devices + # PennyLane native devices are always local simulators if any( pattern in device_name for pattern in ("default.", "lightning.", "numpy", "mixed", "qutrit") ): - return "pennylane" + return "local" # Check module for other hints if "pennylane" in module: - return "pennylane" + return "local" + + return "local" # Default to local + + +def _detect_sdk_frontend(device: Any) -> str: + """ + Detect the SDK frontend from device name/type. + + This returns the SDK name for tracking purposes (goes in producer.frontends). + + Parameters + ---------- + device : Any + PennyLane device. + + Returns + ------- + str + SDK identifier: 'braket', 'qiskit', 'pennylane'. + """ + device_name = get_device_name(device).lower() + short_name = getattr(device, "short_name", "").lower() + module = getattr(device, "__module__", "").lower() + + if ( + device_name.startswith("braket.") + or short_name.startswith("braket.") + or "braket" in module + ): + return "braket" + + if ( + device_name.startswith("qiskit.") + or short_name.startswith("qiskit.") + or "qiskit" in module + ): + return "qiskit" - return "unknown" + return "pennylane" def _detect_backend_type(device: Any, provider: str) -> str: @@ -100,7 +151,7 @@ def _detect_backend_type(device: Any, provider: str) -> str: device : Any PennyLane device. provider : str - Detected provider. + Physical provider (local, aws_braket, ibm_quantum). Returns ------- @@ -109,12 +160,12 @@ def _detect_backend_type(device: Any, provider: str) -> str: """ device_name = get_device_name(device).lower() - # PennyLane native devices are always simulators - if provider == "pennylane": + # Local provider is always simulator + if provider == "local": return "simulator" - # Braket: check for simulator indicators - if provider == "braket": + # AWS Braket: check for simulator indicators + if provider == "aws_braket": if "local" in device_name or "simulator" in device_name: return "simulator" # Check ARN for simulator service @@ -123,8 +174,8 @@ def _detect_backend_type(device: Any, provider: str) -> str: return "simulator" return "hardware" - # Qiskit: check for simulator indicators - if provider == "qiskit": + # IBM Quantum: check for simulator indicators + if provider == "ibm_quantum": if "aer" in device_name or "simulator" in device_name or "fake" in device_name: return "simulator" # Check backend object @@ -541,7 +592,9 @@ def _build_frontend_config(device: Any) -> FrontendConfig: ) -def _build_raw_properties(device: Any, provider: str) -> dict[str, Any]: +def _build_raw_properties( + device: Any, provider: str, sdk_frontend: str +) -> dict[str, Any]: """ Build the complete raw_properties dictionary for a device. @@ -550,7 +603,9 @@ def _build_raw_properties(device: Any, provider: str) -> dict[str, Any]: device : Any PennyLane device instance. provider : str - Detected execution provider. + Physical execution provider (local, aws_braket, ibm_quantum). + sdk_frontend : str + SDK frontend (braket, qiskit, pennylane). Returns ------- @@ -562,6 +617,7 @@ def _build_raw_properties(device: Any, provider: str) -> dict[str, Any]: "device_module": getattr(device, "__module__", ""), "short_name": getattr(device, "short_name", None), "execution_provider": provider, + "sdk_frontend": sdk_frontend, } # Extract wire info @@ -576,10 +632,10 @@ def _build_raw_properties(device: Any, provider: str) -> dict[str, Any]: raw_properties.update(_extract_seed(device)) raw_properties.update(_extract_capabilities(device)) - # Extract provider-specific properties - if provider == "braket": + # Extract SDK-specific properties based on frontend + if sdk_frontend == "braket": raw_properties.update(_extract_braket_info(device)) - elif provider == "qiskit": + elif sdk_frontend == "qiskit": raw_properties.update(_extract_qiskit_info(device)) return raw_properties @@ -641,7 +697,7 @@ def create_device_snapshot( >>> dev = qml.device("braket.aws.qubit", wires=2, device_arn="...") >>> snapshot = create_device_snapshot(dev) >>> snapshot.provider - 'braket' + 'aws_braket' """ if device is None: raise ValueError("Cannot create device snapshot from None device") @@ -650,12 +706,18 @@ def create_device_snapshot( backend_name = get_device_name(device) sdk_versions = collect_sdk_versions() - # Detect execution provider + # Detect physical execution provider and SDK frontend try: provider = _detect_execution_provider(device) except Exception as e: logger.debug("Failed to detect execution provider: %s", e) - provider = "unknown" + provider = "local" + + try: + sdk_frontend = _detect_sdk_frontend(device) + except Exception as e: + logger.debug("Failed to detect SDK frontend: %s", e) + sdk_frontend = "pennylane" try: backend_type = _detect_backend_type(device, provider) @@ -670,11 +732,11 @@ def create_device_snapshot( logger.debug("Failed to build frontend config: %s", e) frontend = None - # Try to resolve execution backend for Braket/Qiskit + # Try to resolve execution backend for Braket/Qiskit (based on SDK frontend) resolved_backend: DeviceSnapshot | None = None backend_id: str | None = None - if provider == "braket": + if sdk_frontend == "braket": try: resolved_backend = _resolve_braket_backend( device, resolve_remote=resolve_remote_backend @@ -691,7 +753,7 @@ def create_device_snapshot( except Exception: pass - elif provider == "qiskit": + elif sdk_frontend == "qiskit": try: resolved_backend = _resolve_qiskit_backend(device) except Exception as e: @@ -738,7 +800,7 @@ def create_device_snapshot( raw_properties_ref = None if tracker is not None: try: - raw_properties = _build_raw_properties(device, provider) + raw_properties = _build_raw_properties(device, provider, sdk_frontend) raw_properties_ref = tracker.log_json( name="device_raw_properties", obj=raw_properties, @@ -781,7 +843,8 @@ def resolve_pennylane_backend(device: Any) -> dict[str, Any] | None: ------- dict or None Dictionary with resolved backend information: - - ``provider``: Execution provider (braket/qiskit/pennylane) + - ``provider``: Physical execution provider (local/aws_braket/ibm_quantum) + - ``sdk_frontend``: SDK frontend (braket/qiskit/pennylane) - ``backend_name``: Device name - ``backend_id``: Stable unique ID (ARN for Braket, etc.) - ``backend_type``: Type (hardware/simulator) @@ -795,7 +858,7 @@ def resolve_pennylane_backend(device: Any) -> dict[str, Any] | None: >>> dev = qml.device("braket.aws.qubit", wires=2, device_arn="...") >>> info = resolve_pennylane_backend(dev) >>> info["provider"] - 'braket' + 'aws_braket' """ if device is None: return None @@ -803,7 +866,12 @@ def resolve_pennylane_backend(device: Any) -> dict[str, Any] | None: try: provider = _detect_execution_provider(device) except Exception: - provider = "unknown" + provider = "local" + + try: + sdk_frontend = _detect_sdk_frontend(device) + except Exception: + sdk_frontend = "pennylane" try: backend_name = get_device_name(device) @@ -817,13 +885,15 @@ def resolve_pennylane_backend(device: Any) -> dict[str, Any] | None: result: dict[str, Any] = { "provider": provider, + "sdk_frontend": sdk_frontend, "backend_name": backend_name, "backend_id": None, "backend_type": backend_type, "backend_obj": None, } - if provider == "braket": + # Use sdk_frontend for backend-specific resolution + if sdk_frontend == "braket": try: arn = getattr(device, "device_arn", None) or getattr( device, "_device_arn", None @@ -833,7 +903,7 @@ def resolve_pennylane_backend(device: Any) -> dict[str, Any] | None: except Exception: pass - elif provider == "qiskit": + elif sdk_frontend == "qiskit": try: backend = getattr(device, "backend", None) or getattr( device, "_backend", None diff --git a/packages/devqubit-pennylane/src/devqubit_pennylane/utils.py b/packages/devqubit-pennylane/src/devqubit_pennylane/utils.py index 715f152..bef2a91 100644 --- a/packages/devqubit-pennylane/src/devqubit_pennylane/utils.py +++ b/packages/devqubit-pennylane/src/devqubit_pennylane/utils.py @@ -212,6 +212,16 @@ def pennylane_version() -> str: return "unknown" +def get_adapter_version() -> str: + """Get adapter version dynamically from package metadata.""" + try: + from importlib.metadata import version + + return version("devqubit-pennylane") + except Exception: + return "unknown" + + def collect_sdk_versions() -> dict[str, str]: """ Collect version strings for all relevant SDK packages. diff --git a/packages/devqubit-pennylane/tests/test_pennylane_adapter.py b/packages/devqubit-pennylane/tests/test_pennylane_adapter.py index bbb6a5e..1b4c54f 100644 --- a/packages/devqubit-pennylane/tests/test_pennylane_adapter.py +++ b/packages/devqubit-pennylane/tests/test_pennylane_adapter.py @@ -41,7 +41,8 @@ def test_describe_executor(self, default_qubit): desc = PennyLaneAdapter().describe_executor(default_qubit) assert desc["name"] == "default.qubit" - assert desc["provider"] == "pennylane" + assert desc["provider"] == "local" # Physical provider for native PL devices + assert desc["sdk"] == "pennylane" # SDK frontend assert desc["num_wires"] == 2 def test_describe_executor_shows_analytic_mode(self, default_qubit): @@ -158,9 +159,11 @@ def circuit(): loaded = registry.load(run.run_id) assert loaded.status == "FINISHED" - assert loaded.record["data"]["tags"]["provider"] == "pennylane" + assert loaded.record["data"]["tags"]["provider"] == "local" # Physical provider + assert loaded.record["data"]["tags"]["sdk"] == "pennylane" # SDK frontend assert loaded.record["backend"]["name"] == "default.qubit" - assert loaded.record["backend"]["provider"] == "pennylane" + assert loaded.record["backend"]["provider"] == "local" # Physical provider + assert loaded.record["backend"]["sdk"] == "pennylane" # SDK frontend def test_execution_count_incremented(self, store, registry, default_qubit): """Execution count is incremented correctly.""" diff --git a/packages/devqubit-pennylane/tests/test_pennylane_results.py b/packages/devqubit-pennylane/tests/test_pennylane_results.py index 5891f17..8e8bbb2 100644 --- a/packages/devqubit-pennylane/tests/test_pennylane_results.py +++ b/packages/devqubit-pennylane/tests/test_pennylane_results.py @@ -5,7 +5,6 @@ import numpy as np import pennylane as qml -from devqubit_engine.uec.types import ResultType from devqubit_pennylane.results import ( _extract_probabilities, _extract_sample_counts, @@ -124,7 +123,7 @@ def test_numpy_scalar(self): class TestBuildResultSnapshot: - """Tests for result snapshot building.""" + """Tests for result snapshot building (UEC 1.0 API).""" def test_expectations_two_circuits(self): """Builds expectation snapshot for multiple circuits.""" @@ -135,10 +134,13 @@ def test_expectations_two_circuits(self): num_circuits=2, ) - assert snap.result_type == ResultType.EXPECTATION - assert snap.expectations is not None - assert [e.circuit_index for e in snap.expectations] == [0, 1] - assert [e.value for e in snap.expectations] == [0.125, -0.5] + assert snap.status == "completed" + assert snap.success is True + assert len(snap.items) == 2 + assert snap.items[0].item_index == 0 + assert snap.items[1].item_index == 1 + assert snap.items[0].expectation.value == 0.125 + assert snap.items[1].expectation.value == -0.5 def test_expectations_single_circuit(self): """Builds expectation snapshot for single circuit.""" @@ -149,11 +151,10 @@ def test_expectations_single_circuit(self): num_circuits=1, ) - assert snap.result_type == ResultType.EXPECTATION - assert snap.expectations is not None - assert len(snap.expectations) == 1 - assert snap.expectations[0].value == 0.5 - assert snap.expectations[0].circuit_index == 0 + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.items[0].expectation.value == 0.5 + assert snap.items[0].expectation.circuit_index == 0 def test_expectations_multiple_observables_single_circuit(self): """Handles multiple observables for single circuit.""" @@ -165,10 +166,9 @@ def test_expectations_multiple_observables_single_circuit(self): num_circuits=1, ) - assert snap.result_type == ResultType.EXPECTATION - assert snap.expectations is not None - assert len(snap.expectations) == 3 - assert [e.observable_index for e in snap.expectations] == [0, 1, 2] + assert snap.status == "completed" + assert len(snap.items) == 3 + assert [item.expectation.observable_index for item in snap.items] == [0, 1, 2] def test_counts_dict(self): """Builds counts snapshot from dict.""" @@ -179,11 +179,11 @@ def test_counts_dict(self): num_circuits=1, ) - assert snap.result_type == ResultType.COUNTS - assert snap.counts is not None and len(snap.counts) == 1 - assert snap.counts[0].shots == 5 - assert snap.counts[0].counts["0"] == 2 - assert snap.counts[0].counts["1"] == 3 + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.items[0].counts["shots"] == 5 + assert snap.items[0].counts["counts"]["0"] == 2 + assert snap.items[0].counts["counts"]["1"] == 3 def test_samples_bitstring_single_circuit(self): """ @@ -209,11 +209,11 @@ def test_samples_bitstring_single_circuit(self): num_circuits=1, ) - assert snap.result_type == ResultType.SAMPLES - assert snap.counts is not None and len(snap.counts) == 1 - assert snap.counts[0].shots == 4 + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.items[0].counts["shots"] == 4 - counts = snap.counts[0].counts + counts = snap.items[0].counts["counts"] assert counts["00"] == 2 assert counts["01"] == 1 assert counts["11"] == 1 @@ -235,11 +235,11 @@ def test_samples_eigenvalue_single_circuit(self): num_circuits=1, ) - assert snap.result_type == ResultType.SAMPLES - assert snap.counts is not None and len(snap.counts) == 1 - assert snap.counts[0].shots == 5 + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.items[0].counts["shots"] == 5 - counts = snap.counts[0].counts + counts = snap.items[0].counts["counts"] assert counts["1"] == 3 assert counts["-1"] == 2 @@ -258,9 +258,9 @@ def test_samples_batch_circuits(self): num_circuits=2, ) - assert snap.counts is not None and len(snap.counts) == 2 - assert snap.counts[0].shots == 3 - assert snap.counts[1].shots == 2 + assert len(snap.items) == 2 + assert snap.items[0].counts["shots"] == 3 + assert snap.items[1].counts["shots"] == 2 def test_probabilities_single_circuit_flat(self): """ @@ -279,11 +279,11 @@ def test_probabilities_single_circuit_flat(self): num_circuits=1, ) - assert snap.result_type == ResultType.QUASI_DIST - assert snap.counts is not None and len(snap.counts) == 1 - assert snap.counts[0].shots is None # Probabilities don't have shots + assert snap.status == "completed" + assert len(snap.items) == 1 + assert snap.items[0].counts["shots"] is None # Probabilities don't have shots - dist = snap.counts[0].counts + dist = snap.items[0].counts["counts"] assert set(dist.keys()) == {"00", "01"} assert abs(dist["00"] - 0.5) < 1e-9 assert abs(dist["01"] - 0.5) < 1e-9 @@ -303,20 +303,20 @@ def test_probabilities_batch_circuits(self): num_circuits=2, ) - assert snap.result_type == ResultType.QUASI_DIST - assert snap.counts is not None and len(snap.counts) == 2 + assert snap.status == "completed" + assert len(snap.items) == 2 # First circuit - assert abs(sum(snap.counts[0].counts.values()) - 1.0) < 1e-9 + assert abs(sum(snap.items[0].counts["counts"].values()) - 1.0) < 1e-9 # Second circuit (uniform) - dist1 = snap.counts[1].counts + dist1 = snap.items[1].counts["counts"] assert len(dist1) == 4 for v in dist1.values(): assert abs(v - 0.25) < 1e-9 def test_unknown_type_goes_to_other(self): - """Unknown result type goes to OTHER.""" + """Unknown result type stores in metadata.""" snap = build_result_snapshot( "raw-result", result_type=None, @@ -324,9 +324,9 @@ def test_unknown_type_goes_to_other(self): num_circuits=1, ) - assert snap.result_type == ResultType.OTHER - assert snap.counts is None - assert snap.expectations is None + assert snap.status == "completed" + assert snap.metadata["pennylane_result_type"] is None + assert len(snap.items) == 0 # No structured items for unknown type def test_failed_execution_snapshot(self): """Builds snapshot for failed execution.""" @@ -340,9 +340,10 @@ def test_failed_execution_snapshot(self): ) assert snap.success is False - assert snap.metadata["error"]["type"] == "RuntimeError" - assert snap.counts is None - assert snap.expectations is None + assert snap.status == "failed" + assert snap.error.type == "RuntimeError" + assert snap.error.message == "Backend failed" + assert len(snap.items) == 0 class TestExtractProbabilities: @@ -438,38 +439,41 @@ def test_empty_results(self): ) assert snap.success is True - assert snap.expectations == [] or snap.expectations is None + assert len(snap.items) == 0 # No items for empty results class TestResultTypeMapping: - """Tests for result type to enum mapping.""" + """Tests for result type stored in metadata.""" - def test_sample_maps_to_samples(self): - """Sample type maps to SAMPLES enum.""" + def test_sample_maps_correctly(self): + """Sample type is stored in metadata.""" snap = build_result_snapshot( np.array([0, 1, 0]), result_type="Sample", backend_name="default.qubit", num_circuits=1, ) - assert snap.result_type == ResultType.SAMPLES + assert snap.metadata["pennylane_result_type"] == "Sample" + assert snap.status == "completed" - def test_counts_maps_to_counts(self): - """Counts type maps to COUNTS enum.""" + def test_counts_maps_correctly(self): + """Counts type is stored in metadata.""" snap = build_result_snapshot( {"0": 5, "1": 5}, result_type="Counts", backend_name="default.qubit", num_circuits=1, ) - assert snap.result_type == ResultType.COUNTS + assert snap.metadata["pennylane_result_type"] == "Counts" + assert snap.status == "completed" - def test_state_maps_to_statevector(self): - """State type maps to STATEVECTOR enum.""" + def test_state_maps_correctly(self): + """State type is stored in metadata.""" snap = build_result_snapshot( np.array([0.707, 0.707]), result_type="State", backend_name="default.qubit", num_circuits=1, ) - assert snap.result_type == ResultType.STATEVECTOR + assert snap.metadata["pennylane_result_type"] == "State" + assert snap.status == "completed" diff --git a/packages/devqubit-pennylane/tests/test_pennylane_snapshot.py b/packages/devqubit-pennylane/tests/test_pennylane_snapshot.py index c0a33d2..379a131 100644 --- a/packages/devqubit-pennylane/tests/test_pennylane_snapshot.py +++ b/packages/devqubit-pennylane/tests/test_pennylane_snapshot.py @@ -184,7 +184,7 @@ def test_raw_properties_content_is_valid(self, default_qubit, store, registry): assert "device_class" in raw_props assert "device_module" in raw_props assert "execution_provider" in raw_props - assert raw_props["execution_provider"] == "pennylane" + assert raw_props["execution_provider"] == "local" def test_raw_properties_includes_shots_info(self, default_qubit, store, registry): """raw_properties artifact should include shots configuration.""" @@ -220,7 +220,7 @@ def test_default_qubit_core_fields(self, default_qubit): """Creates snapshot with core fields from default.qubit.""" snap = create_device_snapshot(default_qubit) - assert snap.provider == "pennylane" + assert snap.provider == "local" assert snap.backend_name == "default.qubit" assert snap.backend_type == "simulator" assert snap.captured_at is not None @@ -238,7 +238,7 @@ def test_snapshot_to_dict(self, default_qubit): snap = create_device_snapshot(default_qubit) d = snap.to_dict() - assert d["provider"] == "pennylane" + assert d["provider"] == "local" assert d["backend_name"] == "default.qubit" assert "captured_at" in d assert "num_qubits" in d @@ -266,7 +266,7 @@ def test_lightning_qubit_if_available(self): dev = qml.device("lightning.qubit", wires=3) snap = create_device_snapshot(dev) - assert snap.provider == "pennylane" + assert snap.provider == "local" assert "lightning" in snap.backend_name assert snap.num_qubits == 3 except Exception: @@ -278,7 +278,7 @@ def test_default_mixed_if_available(self): dev = qml.device("default.mixed", wires=2) snap = create_device_snapshot(dev) - assert snap.provider == "pennylane" + assert snap.provider == "local" assert "mixed" in snap.backend_name assert snap.num_qubits == 2 except Exception: @@ -297,7 +297,7 @@ class MinimalDevice: snap = create_device_snapshot(MinimalDevice()) assert snap.backend_name == "minimal" - assert snap.provider == "pennylane" + assert snap.provider == "local" assert snap.num_qubits is None def test_device_with_wires_only(self): @@ -387,7 +387,8 @@ class MockBraketDevice: info = resolve_pennylane_backend(MockBraketDevice()) assert info is not None - assert info["provider"] == "braket" + assert info["provider"] == "aws_braket" # Physical provider + assert info["sdk_frontend"] == "braket" # SDK frontend assert info["backend_type"] == "simulator" assert info["backend_id"] == MockBraketDevice.device_arn @@ -412,7 +413,8 @@ class MockQiskitDevice: snap = create_device_snapshot(MockQiskitDevice()) - assert snap.provider == "qiskit" + # aer_simulator is local, so provider is "local" + assert snap.provider == "local" # Physical provider (aer is local) assert snap.backend_id == "ibm-backend-123" assert snap.backend_type == "simulator" assert snap.num_qubits == 2 @@ -432,7 +434,8 @@ class MockBraketHardware: info = resolve_pennylane_backend(MockBraketHardware()) - assert info["provider"] == "braket" + assert info["provider"] == "aws_braket" # Physical provider + assert info["sdk_frontend"] == "braket" # SDK frontend assert info["backend_type"] == "hardware" # Not simulator @@ -528,7 +531,7 @@ def test_to_dict_roundtrip_types(self, default_qubit): json_str = json.dumps(d) parsed = json.loads(json_str) - assert parsed["provider"] == "pennylane" + assert parsed["provider"] == "local" assert parsed["backend_name"] == "default.qubit" def test_frontend_config_serializable(self, default_qubit): diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/adapter.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/adapter.py index b479c50..7158840 100644 --- a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/adapter.py +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/adapter.py @@ -57,10 +57,9 @@ from __future__ import annotations -import hashlib import logging +import uuid import warnings -from collections import defaultdict from dataclasses import dataclass, field from typing import Any @@ -69,6 +68,7 @@ from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.envelope import ExecutionEnvelope from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.producer import ProducerInfo from devqubit_engine.uec.program import ( ProgramArtifact, ProgramSnapshot, @@ -82,6 +82,15 @@ from devqubit_engine.utils.serialization import to_jsonable from devqubit_engine.utils.time_utils import utc_now_iso from devqubit_qiskit.serialization import QiskitCircuitSerializer +from devqubit_qiskit_runtime.circuits import ( + circuits_to_text, + compute_circuit_hash, +) +from devqubit_qiskit_runtime.envelope import ( + create_failure_result_snapshot, + detect_physical_provider, + finalize_envelope_with_result, +) from devqubit_qiskit_runtime.pubs import ( extract_circuits_from_pubs, extract_pubs_structure, @@ -100,6 +109,7 @@ from devqubit_qiskit_runtime.utils import ( collect_sdk_versions, extract_job_id, + get_adapter_version, get_backend_name, get_primitive_type, is_runtime_primitive, @@ -122,131 +132,6 @@ def _map_transpilation_mode(mode: str) -> TranspilationMode: return mode_map.get(mode.lower(), TranspilationMode.AUTO) -def _compute_circuit_hash(circuits: list[Any]) -> str | None: - """ - Compute a structure-only hash for Qiskit QuantumCircuit objects. - - Parameters - ---------- - circuits : list[Any] - List of Qiskit QuantumCircuit objects. - - Returns - ------- - str or None - Full SHA-256 digest in format ``sha256:``, or None if empty. - """ - if not circuits: - return None - - circuit_signatures: list[str] = [] - - for circuit in circuits: - try: - qubit_index = { - q: i for i, q in enumerate(getattr(circuit, "qubits", ()) or ()) - } - clbit_index = { - c: i for i, c in enumerate(getattr(circuit, "clbits", ()) or ()) - } - - op_sigs: list[str] = [] - for instr in getattr(circuit, "data", []) or []: - op = getattr(instr, "operation", None) - name = getattr(op, "name", None) - op_name = name if isinstance(name, str) and name else type(op).__name__ - - qs: list[int] = [] - for q in getattr(instr, "qubits", ()) or (): - if q in qubit_index: - qs.append(qubit_index[q]) - else: - qs.append(getattr(circuit.find_bit(q), "index", -1)) - cs: list[int] = [] - for c in getattr(instr, "clbits", ()) or (): - if c in clbit_index: - cs.append(clbit_index[c]) - else: - cs.append(getattr(circuit.find_bit(c), "index", -1)) - - params = getattr(op, "params", None) - parity = len(params) if isinstance(params, (list, tuple)) else 0 - cond = getattr(op, "condition", None) - has_cond = 1 if cond is not None else 0 - - op_sigs.append( - f"{op_name}|p{parity}|q{tuple(qs)}|c{tuple(cs)}|if{has_cond}" - ) - - circuit_signatures.append("||".join(op_sigs)) - - except Exception: - circuit_signatures.append(str(circuit)[:500]) - - payload = "\n".join(circuit_signatures).encode("utf-8", errors="replace") - return f"sha256:{hashlib.sha256(payload).hexdigest()}" - - -def _circuits_to_text(circuits: list[Any]) -> str: - """Convert circuits to human-readable text diagrams.""" - parts: list[str] = [] - - for i, circuit in enumerate(circuits): - if i > 0: - parts.append("") - - name = getattr(circuit, "name", None) or f"circuit_{i}" - parts.append(f"[{i}] {name}") - - try: - diagram = circuit.draw(output="text", fold=80) - if hasattr(diagram, "single_string"): - parts.append(diagram.single_string()) - else: - parts.append(str(diagram)) - except Exception: - parts.append(str(circuit)) - - return "\n".join(parts) - - -def _finalize_envelope_with_result( - tracker: Run, - envelope: ExecutionEnvelope, - result_snapshot: ResultSnapshot, -) -> None: - """ - Finalize envelope with result and log as artifact. - - Parameters - ---------- - tracker : Run - Tracker instance. - envelope : ExecutionEnvelope - Envelope to finalize. - result_snapshot : ResultSnapshot - Result to add to envelope. - - Raises - ------ - ValueError - If envelope is None. - """ - if envelope is None: - raise ValueError("Cannot finalize None envelope") - - if result_snapshot is None: - logger.warning("Finalizing envelope with None result_snapshot") - - envelope.result = result_snapshot - - if envelope.execution is not None: - envelope.execution.completed_at = utc_now_iso() - - # Validate and log envelope using tracker's canonical method - tracker.log_envelope(envelope=envelope) - - @dataclass class TrackedRuntimeJob: """ @@ -298,15 +183,30 @@ def result(self, *args: Any, **kwargs: Any) -> Any: This method is idempotent - calling it multiple times returns the same cached result and does not re-log artifacts. + UEC Compliance: If job.result() raises an exception, an envelope with + error details is still created and logged before re-raising. + Returns ------- Any PrimitiveResult object. + + Raises + ------ + Exception + Re-raises any exception from the underlying job.result(). """ if self._cached_result is not None: return self._cached_result - result = self.job.result(*args, **kwargs) + # Wrap job.result() to ensure envelope is created even on failure + try: + result = self.job.result(*args, **kwargs) + except Exception as exc: + # Log failure envelope before re-raising + self._log_failure(exc) + raise + self._cached_result = result if not self.should_log_results or self._finalized: @@ -342,7 +242,7 @@ def result(self, *args: Any, **kwargs: Any) -> Any: # Finalize envelope with result if self.envelope is not None and self.result_snapshot is not None: - _finalize_envelope_with_result( + finalize_envelope_with_result( self.tracker, self.envelope, self.result_snapshot, @@ -367,6 +267,50 @@ def result(self, *args: Any, **kwargs: Any) -> Any: return result + def _log_failure(self, exc: Exception) -> None: + """ + Log failure envelope when job.result() raises an exception. + + UEC Compliance: Ensures envelope is always created even on failures. + + Parameters + ---------- + exc : Exception + The exception that caused the failure. + """ + if self._finalized or not self.should_log_results: + return + + self._finalized = True + + try: + # Create failure result snapshot + self.result_snapshot = create_failure_result_snapshot( + exception=exc, + backend_name=self.executor_name, + primitive_type=self.primitive_type, + ) + + # Finalize envelope with failure result + if self.envelope is not None: + finalize_envelope_with_result( + self.tracker, + self.envelope, + self.result_snapshot, + ) + + logger.debug( + "Logged failure envelope for %s: %s", + self.executor_name, + type(exc).__name__, + ) + except Exception as log_exc: + logger.warning( + "Failed to log failure envelope for %s: %s", + self.executor_name, + log_exc, + ) + def _log_sampler_results( self, result: Any, @@ -379,15 +323,16 @@ def _log_sampler_results( raw_result_ref=raw_result_ref, ) - if snapshot.counts: + if snapshot.items: counts_payload = { "experiments": [ { - "index": nc.circuit_index, - "counts": nc.counts, - "shots": nc.shots, + "index": item.item_index, + "counts": item.counts.get("counts", {}) if item.counts else {}, + "shots": item.counts.get("shots") if item.counts else None, } - for nc in snapshot.counts + for item in snapshot.items + if item.counts ] } self.tracker.log_json( @@ -397,16 +342,17 @@ def _log_sampler_results( kind="result.counts.json", ) + num_experiments = len(snapshot.items) self.tracker.record["results"] = { "completed_at": utc_now_iso(), "backend_name": self.executor_name, - "num_experiments": snapshot.num_experiments, + "num_experiments": num_experiments, "primitive_type": self.primitive_type, "result_type": "counts", } logger.debug( "Logged sampler counts for %d experiments on %s", - snapshot.num_experiments, + num_experiments, self.executor_name, ) @@ -424,42 +370,50 @@ def _log_estimator_results( raw_result_ref=raw_result_ref, ) - if snapshot.expectations: - # Group by circuit_index for the payload - by_circuit: dict[int, dict[str, list[float]]] = defaultdict( - lambda: {"expectation_values": [], "standard_deviations": []} - ) - for exp in snapshot.expectations: - by_circuit[exp.circuit_index]["expectation_values"].append(exp.value) - if exp.std_error is not None: - by_circuit[exp.circuit_index]["standard_deviations"].append( - exp.std_error - ) - - est_payload = { - "experiments": [ - {"index": idx, **data} for idx, data in sorted(by_circuit.items()) - ] - } - self.tracker.log_json( - name="estimator_values", - obj=est_payload, - role="results", - kind="result.qiskit_runtime.estimator.json", - ) + # Get experiments from snapshot metadata (estimator stores there) + experiments_data = snapshot.metadata.get("experiments", []) + if experiments_data: + # Build payload for artifact + experiments = [] + for exp_data in experiments_data: + expectations = exp_data.get("expectations", []) + if expectations: + evs = [e.get("value", 0.0) for e in expectations] + stds = [ + e.get("std_error") + for e in expectations + if e.get("std_error") is not None + ] + exp_entry = { + "index": exp_data.get("index", 0), + "expectation_values": evs, + } + if stds: + exp_entry["standard_deviations"] = stds + experiments.append(exp_entry) + + if experiments: + est_payload = {"experiments": experiments} + self.tracker.log_json( + name="estimator_values", + obj=est_payload, + role="results", + kind="result.qiskit_runtime.estimator.json", + ) - self.tracker.record["results"] = { - "completed_at": utc_now_iso(), - "backend_name": self.executor_name, - "num_experiments": snapshot.num_experiments, - "primitive_type": self.primitive_type, - "result_type": "expectation", - } - logger.debug( - "Logged estimator values for %d experiments on %s", - snapshot.num_experiments, - self.executor_name, - ) + num_experiments = len(experiments) + self.tracker.record["results"] = { + "completed_at": utc_now_iso(), + "backend_name": self.executor_name, + "num_experiments": num_experiments, + "primitive_type": self.primitive_type, + "result_type": "expectation", + } + logger.debug( + "Logged estimator values for %d experiments on %s", + num_experiments, + self.executor_name, + ) return snapshot @@ -641,7 +595,7 @@ def _log_circuits( # Log circuit diagrams try: - diagram_text = _circuits_to_text(circuits) + diagram_text = circuits_to_text(circuits) ref = self.tracker.log_bytes( kind="qiskit_runtime.circuits.diagram", data=diagram_text.encode("utf-8"), @@ -749,7 +703,7 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: exec_count = self._execution_count # Compute circuit hash - circuit_hash = _compute_circuit_hash(circuits) + circuit_hash = compute_circuit_hash(circuits) is_new_circuit = circuit_hash and circuit_hash not in self._seen_circuit_hashes if circuit_hash: self._seen_circuit_hashes.add(circuit_hash) @@ -810,8 +764,9 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: envelope=None, ) - # Tags - self.tracker.set_tag("provider", "qiskit-ibm-runtime") + # Tags - use physical provider, not SDK + physical_provider = detect_physical_provider(self.primitive) + self.tracker.set_tag("provider", physical_provider) self.tracker.set_tag("adapter", "qiskit-runtime") self.tracker.set_tag("backend_name", exec_name) self.tracker.set_tag("primitive_type", self.primitive_type) @@ -838,7 +793,7 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: # If devqubit transpiled, log transpiled circuits as physical if transpiled_circuits: - transpiled_hash = _compute_circuit_hash(transpiled_circuits) + transpiled_hash = compute_circuit_hash(transpiled_circuits) try: qpy_data = _serializer.serialize( ( @@ -877,7 +832,7 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: self.tracker.record["backend"] = { "name": exec_name, "type": self.primitive.__class__.__name__, - "provider": "qiskit-ibm-runtime", + "provider": physical_provider, # Physical provider, not SDK "primitive_type": self.primitive_type, } @@ -885,7 +840,7 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: # Build ProgramSnapshot executed_hash = ( - _compute_circuit_hash(transpiled_circuits) if transpiled_circuits else None + compute_circuit_hash(transpiled_circuits) if transpiled_circuits else None ) self.program_snapshot = ProgramSnapshot( @@ -905,11 +860,6 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: transpilation_info = TranspilationInfo( mode=transpilation_mode, transpiled_by=transpiled_by, - optimization_level=options.optimization_level, - layout_method=options.layout_method, - routing_method=options.routing_method, - seed=options.seed_transpiler, - pass_manager_config=options.to_metadata_dict(), ) # Build ExecutionSnapshot @@ -925,6 +875,11 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: "transpilation_needed": tmeta.get("transpilation_needed"), "transpilation_reason": tmeta.get("transpilation_reason"), "observables_layout_mapped": tmeta.get("observables_layout_mapped"), + # Store transpilation details here instead + "optimization_level": options.optimization_level, + "layout_method": options.layout_method, + "routing_method": options.routing_method, + "seed_transpiler": options.seed_transpiler, }, sdk="qiskit-ibm-runtime", ) @@ -1010,14 +965,33 @@ def run(self, pubs: Any, *args: Any, **kwargs: Any) -> TrackedRuntimeJob: # Create envelope (will be finalized when result() is called) envelope: ExecutionEnvelope | None = None if should_log_results and device_snapshot is not None: + sdk_versions = collect_sdk_versions() + + # Create ProducerInfo for SDK stack tracking + producer = ProducerInfo.create( + adapter="devqubit-qiskit-runtime", + adapter_version=get_adapter_version(), + sdk="qiskit-ibm-runtime", + sdk_version=sdk_versions.get("qiskit_ibm_runtime", "unknown"), + frontends=["qiskit-ibm-runtime"], + ) + + # Create pending result (will be updated when result() completes) + pending_result = ResultSnapshot( + success=False, + status="failed", # Will be updated when result() completes + items=[], + metadata={"state": "pending"}, + ) + envelope = ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="qiskit-runtime", + envelope_id=uuid.uuid4().hex[:26], created_at=utc_now_iso(), + producer=producer, + result=pending_result, device=device_snapshot, program=self.program_snapshot, execution=self.execution_snapshot, - result=None, # Will be filled when result() is called ) # Update stats @@ -1091,7 +1065,7 @@ def describe_executor(self, executor: Any) -> dict[str, Any]: return { "name": get_backend_name(executor), "type": executor.__class__.__name__, - "provider": "qiskit-ibm-runtime", + "provider": detect_physical_provider(executor), # Physical provider "primitive_type": get_primitive_type(executor), } diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/circuits.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/circuits.py new file mode 100644 index 0000000..3a06a66 --- /dev/null +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/circuits.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Circuit handling utilities for Qiskit Runtime adapter. + +This module provides functions for hashing and converting Qiskit +QuantumCircuit objects for logging purposes. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + + +def compute_circuit_hash(circuits: list[Any]) -> str | None: + """ + Compute a structure-only hash for Qiskit QuantumCircuit objects. + + Captures circuit structure (gates, qubits, classical bits) while + ignoring parameter values for deduplication purposes. + + Parameters + ---------- + circuits : list[Any] + List of Qiskit QuantumCircuit objects. + + Returns + ------- + str or None + Full SHA-256 digest in format ``sha256:``, or None if empty. + + Notes + ----- + The hash captures: + - Operation names (e.g., 'rx', 'cx', 'measure') + - Ordered qubit indices + - Ordered clbit indices (measurement wiring) + - Parameter arity (count only, not values) + - Classical condition presence + """ + if not circuits: + return None + + circuit_signatures: list[str] = [] + + for circuit in circuits: + try: + qubit_index = { + q: i for i, q in enumerate(getattr(circuit, "qubits", ()) or ()) + } + clbit_index = { + c: i for i, c in enumerate(getattr(circuit, "clbits", ()) or ()) + } + + op_sigs: list[str] = [] + for instr in getattr(circuit, "data", []) or []: + op = getattr(instr, "operation", None) + name = getattr(op, "name", None) + op_name = name if isinstance(name, str) and name else type(op).__name__ + + qs: list[int] = [] + for q in getattr(instr, "qubits", ()) or (): + if q in qubit_index: + qs.append(qubit_index[q]) + else: + qs.append(getattr(circuit.find_bit(q), "index", -1)) + cs: list[int] = [] + for c in getattr(instr, "clbits", ()) or (): + if c in clbit_index: + cs.append(clbit_index[c]) + else: + cs.append(getattr(circuit.find_bit(c), "index", -1)) + + params = getattr(op, "params", None) + parity = len(params) if isinstance(params, (list, tuple)) else 0 + cond = getattr(op, "condition", None) + has_cond = 1 if cond is not None else 0 + + op_sigs.append( + f"{op_name}|p{parity}|q{tuple(qs)}|c{tuple(cs)}|if{has_cond}" + ) + + circuit_signatures.append("||".join(op_sigs)) + + except Exception: + circuit_signatures.append(str(circuit)[:500]) + + payload = "\n".join(circuit_signatures).encode("utf-8", errors="replace") + return f"sha256:{hashlib.sha256(payload).hexdigest()}" + + +def circuits_to_text(circuits: list[Any]) -> str: + """ + Convert circuits to human-readable text diagrams. + + Parameters + ---------- + circuits : list + List of QuantumCircuit objects. + + Returns + ------- + str + Combined text diagram of all circuits. + """ + parts: list[str] = [] + + for i, circuit in enumerate(circuits): + if i > 0: + parts.append("") + + name = getattr(circuit, "name", None) or f"circuit_{i}" + parts.append(f"[{i}] {name}") + + try: + diagram = circuit.draw(output="text", fold=80) + if hasattr(diagram, "single_string"): + parts.append(diagram.single_string()) + else: + parts.append(str(diagram)) + except Exception: + parts.append(str(circuit)) + + return "\n".join(parts) diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/envelope.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/envelope.py new file mode 100644 index 0000000..75e2401 --- /dev/null +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/envelope.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Envelope and snapshot utilities for Qiskit Runtime adapter. + +This module provides functions for creating UEC snapshots and +managing ExecutionEnvelope lifecycle. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from devqubit_engine.core.run import Run +from devqubit_engine.uec.envelope import ExecutionEnvelope +from devqubit_engine.uec.result import ResultSnapshot +from devqubit_engine.utils.time_utils import utc_now_iso +from devqubit_qiskit_runtime.utils import get_backend_name, get_backend_obj + + +logger = logging.getLogger(__name__) + + +def detect_physical_provider(primitive: Any) -> str: + """ + Detect physical provider from Runtime primitive (not SDK). + + UEC requires provider to be the physical backend provider, + not the SDK name. SDK goes in producer.frontends[]. + + Parameters + ---------- + primitive : Any + Runtime primitive instance. + + Returns + ------- + str + Physical provider: "ibm_quantum", "fake", or "local". + """ + backend = get_backend_obj(primitive) + if backend is None: + # No backend resolved - check primitive module + module_name = getattr(primitive, "__module__", "").lower() + if "ibm" in module_name: + return "ibm_quantum" + return "local" + + module_name = type(backend).__module__.lower() + backend_name = get_backend_name(primitive).lower() + + # IBM quantum hardware + if "ibm" in module_name or "ibm_" in backend_name: + # Check for fake backends + if "fake" in module_name or "fake" in backend_name: + return "fake" + return "ibm_quantum" + + # Aer simulator (local) + if "aer" in module_name: + return "aer" + + return "local" + + +def create_failure_result_snapshot( + exception: BaseException, + backend_name: str, + primitive_type: str, +) -> ResultSnapshot: + """ + Create a ResultSnapshot for a failed execution. + + Used when job.result() raises an exception. Ensures envelope + is always created even on failures (UEC requirement). + + Parameters + ---------- + exception : BaseException + The exception that caused the failure. + backend_name : str + Backend name for metadata. + primitive_type : str + Type of primitive ('sampler' or 'estimator'). + + Returns + ------- + ResultSnapshot + Failed result snapshot with error details. + """ + from devqubit_engine.uec.result import ResultSnapshot + + return ResultSnapshot.create_failed( + exception=exception, + metadata={ + "backend_name": backend_name, + "primitive_type": primitive_type, + }, + ) + + +def finalize_envelope_with_result( + tracker: Run, + envelope: ExecutionEnvelope, + result_snapshot: ResultSnapshot, +) -> None: + """ + Finalize envelope with result and log as artifact. + + Parameters + ---------- + tracker : Run + Tracker instance. + envelope : ExecutionEnvelope + Envelope to finalize. + result_snapshot : ResultSnapshot + Result to add to envelope. + + Raises + ------ + ValueError + If envelope is None. + """ + if envelope is None: + raise ValueError("Cannot finalize None envelope") + + if result_snapshot is None: + logger.warning("Finalizing envelope with None result_snapshot") + + # Add result to envelope + envelope.result = result_snapshot + + # Set completion time + if envelope.execution is not None: + envelope.execution.completed_at = utc_now_iso() + + # Validate and log envelope using tracker's canonical method + tracker.log_envelope(envelope=envelope) diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/results.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/results.py index 6eb5ca0..6bf4c7a 100644 --- a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/results.py +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/results.py @@ -15,11 +15,11 @@ from typing import TYPE_CHECKING, Any from devqubit_engine.uec.result import ( - ExpectationValue, - NormalizedCounts, + CountsFormat, + ResultError, + ResultItem, ResultSnapshot, ) -from devqubit_engine.uec.types import ResultType from devqubit_engine.utils.serialization import to_jsonable @@ -221,6 +221,9 @@ def build_sampler_result_snapshot( """ Build a ResultSnapshot from a Sampler result. + Uses UEC 1.0 structure with items[], CountsFormat for + cross-SDK comparability. + Parameters ---------- result : Any @@ -233,16 +236,15 @@ def build_sampler_result_snapshot( Returns ------- ResultSnapshot - Structured result snapshot with normalized counts. + Structured result snapshot with items[]. """ if result is None: return ResultSnapshot( - result_type=ResultType.COUNTS, - raw_result_ref=raw_result_ref, - counts=[], - num_experiments=0, success=False, - error_message="Result is None", + status="failed", + items=[], + error=ResultError(type="NullResult", message="Result is None"), + raw_result_ref=raw_result_ref, metadata={ "backend_name": backend_name, "primitive_type": "sampler", @@ -250,27 +252,44 @@ def build_sampler_result_snapshot( ) counts_data = extract_sampler_results(result) - normalized_counts: list[NormalizedCounts] = [] + # Qiskit Runtime counts format metadata + # Qiskit uses little-endian (cbit[0] on right) = UEC canonical + counts_format = CountsFormat( + source_sdk="qiskit-ibm-runtime", + source_key_format="qiskit_little_endian", + bit_order="cbit0_right", + transformed=False, + ) + + items: list[ResultItem] = [] if counts_data: for exp in counts_data.get("experiments", []): - normalized_counts.append( - NormalizedCounts( - circuit_index=exp.get("index", 0), - counts=exp.get("counts", {}), - shots=exp.get("shots"), + counts = exp.get("counts", {}) + shots = exp.get("shots") + item_index = exp.get("index", 0) + + items.append( + ResultItem( + item_index=item_index, + success=True, + counts={ + "counts": counts, + "shots": shots, + "format": counts_format.to_dict(), + }, ) ) return ResultSnapshot( - result_type=ResultType.COUNTS, - raw_result_ref=raw_result_ref, - counts=normalized_counts, - num_experiments=len(normalized_counts), success=True, + status="completed", + items=items, + raw_result_ref=raw_result_ref, metadata={ "backend_name": backend_name, "primitive_type": "sampler", + "num_experiments": len(items), }, ) @@ -343,6 +362,9 @@ def build_estimator_result_snapshot( """ Build a ResultSnapshot from an Estimator result. + Uses UEC 1.0 structure. Since ResultItem is designed for counts, + estimator expectations are stored in ResultSnapshot.metadata. + Parameters ---------- result : Any @@ -355,16 +377,15 @@ def build_estimator_result_snapshot( Returns ------- ResultSnapshot - Structured result snapshot with normalized expectations. + Structured result snapshot with expectations in metadata. """ if result is None: return ResultSnapshot( - result_type=ResultType.EXPECTATION, - raw_result_ref=raw_result_ref, - expectations=[], - num_experiments=0, success=False, - error_message="Result is None", + status="failed", + items=[], + error=ResultError(type="NullResult", message="Result is None"), + raw_result_ref=raw_result_ref, metadata={ "backend_name": backend_name, "primitive_type": "estimator", @@ -372,10 +393,12 @@ def build_estimator_result_snapshot( ) est_data = extract_estimator_results(result) - normalized_expectations: list[ExpectationValue] = [] + # Build experiments list for metadata (estimator doesn't use ResultItem.counts) + experiments_data = [] if est_data: for exp in est_data.get("experiments", []): + item_index = exp.get("index", 0) evs = exp.get("expectation_values", []) stds = exp.get("standard_deviations", []) @@ -385,26 +408,36 @@ def build_estimator_result_snapshot( if not isinstance(stds, list): stds = [stds] if stds else [] + # Build expectations list + expectations = [] for obs_idx, ev in enumerate(evs): std = stds[obs_idx] if obs_idx < len(stds) else None - normalized_expectations.append( - ExpectationValue( - circuit_index=exp.get("index", 0), - observable_index=obs_idx, - value=float(ev) if ev is not None else 0.0, - std_error=float(std) if std is not None else None, - ) + expectations.append( + { + "observable_index": obs_idx, + "value": float(ev) if ev is not None else 0.0, + "std_error": float(std) if std is not None else None, + } ) + experiments_data.append( + { + "index": item_index, + "expectations": expectations, + "num_observables": len(expectations), + } + ) + return ResultSnapshot( - result_type=ResultType.EXPECTATION, - raw_result_ref=raw_result_ref, - expectations=normalized_expectations, - num_experiments=len(est_data.get("experiments", [])) if est_data else 0, success=True, + status="completed", + items=[], # Estimator doesn't produce counts-based items + raw_result_ref=raw_result_ref, metadata={ "backend_name": backend_name, "primitive_type": "estimator", + "num_experiments": len(experiments_data), + "experiments": experiments_data, # Store expectations here }, ) diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/snapshot.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/snapshot.py index c0c5d8b..d59ea6b 100644 --- a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/snapshot.py +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/snapshot.py @@ -41,6 +41,48 @@ logger = logging.getLogger(__name__) +def _detect_physical_provider(primitive: Any) -> str: + """ + Detect physical provider from Runtime primitive (not SDK). + + UEC requires provider to be the physical backend provider, + not the SDK name. SDK goes in producer.frontends[]. + + Parameters + ---------- + primitive : Any + Runtime primitive instance. + + Returns + ------- + str + Physical provider: "ibm_quantum", "fake", or "local". + """ + backend = get_backend_obj(primitive) + if backend is None: + # No backend resolved - check primitive module + module_name = getattr(primitive, "__module__", "").lower() + if "ibm" in module_name: + return "ibm_quantum" + return "local" + + module_name = type(backend).__module__.lower() + backend_name = get_backend_name(primitive).lower() + + # IBM quantum hardware + if "ibm" in module_name or "ibm_" in backend_name: + # Check for fake backends + if "fake" in module_name or "fake" in backend_name: + return "fake" + return "ibm_quantum" + + # Aer simulator (local) + if "aer" in module_name: + return "aer" + + return "local" + + def _extract_backend_id(primitive: Any) -> str | None: """ Extract a stable backend identifier from a Runtime primitive. @@ -371,11 +413,14 @@ def create_device_snapshot( except Exception as e: logger.warning("Failed to log raw_properties artifact: %s", e) + # Detect physical provider (not SDK) + physical_provider = _detect_physical_provider(primitive) + return DeviceSnapshot( captured_at=captured_at, backend_name=backend_name, backend_type=backend_type, - provider="qiskit-ibm-runtime", + provider=physical_provider, backend_id=backend_id, num_qubits=base.num_qubits if base else None, connectivity=base.connectivity if base else None, @@ -437,7 +482,7 @@ def resolve_runtime_backend(executor: Any) -> dict[str, Any] | None: primitive_type = "unknown" return { - "provider": "qiskit-ibm-runtime", + "provider": _detect_physical_provider(executor), "backend_name": backend_name, "backend_id": backend_id, "backend_type": backend_type, diff --git a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/utils.py b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/utils.py index fab3b0e..5d95a6b 100644 --- a/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/utils.py +++ b/packages/devqubit-qiskit-runtime/src/devqubit_qiskit_runtime/utils.py @@ -146,6 +146,16 @@ def get_backend_name(primitive: Any) -> str: return primitive.__class__.__name__ +def get_adapter_version() -> str: + """Get adapter version dynamically from package metadata.""" + try: + from importlib.metadata import version + + return version("devqubit-qiskit-runtime") + except Exception: + return "unknown" + + def get_primitive_type(executor: Any) -> str: """ Determine if primitive is sampler or estimator. diff --git a/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_adapter.py b/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_adapter.py index aa67738..946d412 100644 --- a/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_adapter.py +++ b/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_adapter.py @@ -37,11 +37,6 @@ def _count_kind(loaded, kind: str) -> int: return sum(1 for a in getattr(loaded, "artifacts", []) if a.kind == kind) -def _get_artifacts_by_kind(loaded, kind: str) -> list: - """Get all artifacts of a specific kind.""" - return [a for a in getattr(loaded, "artifacts", []) if a.kind == kind] - - class TestQiskitRuntimeAdapter: """Tests for adapter registration and primitive detection.""" @@ -76,12 +71,16 @@ def test_describe_sampler(self, fake_sampler): """Describes Sampler primitive correctly.""" desc = QiskitRuntimeAdapter().describe_executor(fake_sampler) - assert desc["provider"] == "qiskit-ibm-runtime" + assert desc["provider"] == "fake" assert desc["primitive_type"] == "sampler" + assert "name" in desc + assert "type" in desc def test_describe_estimator(self, fake_estimator): """Describes Estimator primitive correctly.""" desc = QiskitRuntimeAdapter().describe_executor(fake_estimator) + + assert desc["provider"] == "fake" # For FakeEstimatorV2 assert desc["primitive_type"] == "estimator" def test_wrap_executor_returns_tracked_primitive( @@ -143,13 +142,16 @@ def test_envelope_structure_complete( assert len(envelopes) == 1 envelope = envelopes[0] - # Schema and adapter - assert envelope["schema"] == "devqubit.envelope/0.1" - assert envelope["adapter"] == "qiskit-runtime" + # UEC 1.0 schema and producer + assert envelope["schema"] == "devqubit.envelope/1.0" + assert "envelope_id" in envelope + assert "producer" in envelope + assert envelope["producer"]["adapter"] == "devqubit-qiskit-runtime" + assert "qiskit-ibm-runtime" in envelope["producer"]["frontends"] - # Device section + # Device section - provider is physical (fake for fake backends) device = envelope["device"] - assert device["provider"] == "qiskit-ibm-runtime" + assert device["provider"] in ("fake", "ibm_quantum", "aer", "local") valid_types = {"simulator", "hardware", "emulator", "sim", "hw", "qpu"} assert device["backend_type"] in valid_types assert "captured_at" in device @@ -163,9 +165,13 @@ def test_envelope_structure_complete( assert "execution" in envelope assert "transpilation" in envelope["execution"] - # Result section - assert envelope["result"]["result_type"] == "counts" - assert envelope["result"]["num_experiments"] >= 1 + # Result section (UEC 1.0) + result = envelope["result"] + assert result["success"] is True + assert result["status"] == "completed" + assert len(result.get("items", [])) >= 1 + # Primitive type is in metadata + assert result["metadata"]["primitive_type"] == "sampler" class TestEstimatorExecution: @@ -354,7 +360,8 @@ def test_batch_pubs_single_job( loaded, envelopes = _load_envelopes(run.run_id, store, registry) assert loaded.record["execute"]["num_pubs"] == 2 - assert envelopes[0]["result"]["num_experiments"] == 2 + # UEC 1.0: check items count instead of num_experiments + assert len(envelopes[0]["result"]["items"]) == 2 def test_multi_circuit_qasm3_artifacts( self, diff --git a/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_snapshot.py b/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_snapshot.py index d1c7c0d..96a6013 100644 --- a/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_snapshot.py +++ b/packages/devqubit-qiskit-runtime/tests/test_qiskit_runtime_snapshot.py @@ -110,7 +110,8 @@ def test_snapshot_provider_and_timestamp(self, fake_sampler): """Snapshot has correct provider and ISO timestamp.""" snapshot = create_device_snapshot(fake_sampler) - assert snapshot.provider == "qiskit-ibm-runtime" + # Provider should be physical (fake for fake backends) + assert snapshot.provider in ("fake", "ibm_quantum", "aer", "local") assert snapshot.captured_at is not None # Verify ISO format ts = snapshot.captured_at.replace("Z", "+00:00") @@ -121,7 +122,8 @@ def test_snapshot_to_dict(self, fake_sampler): snapshot = create_device_snapshot(fake_sampler) d = snapshot.to_dict() - assert d["provider"] == "qiskit-ibm-runtime" + # Provider should be physical (fake for fake backends) + assert d["provider"] in ("fake", "ibm_quantum", "aer", "local") assert "captured_at" in d assert "num_qubits" in d assert "connectivity" in d @@ -132,7 +134,8 @@ def test_estimator_snapshot(self, fake_estimator): """Snapshot works for Estimator primitive.""" snapshot = create_device_snapshot(fake_estimator) - assert snapshot.provider == "qiskit-ibm-runtime" + # Provider should be physical (fake for fake backends) + assert snapshot.provider in ("fake", "ibm_quantum", "aer", "local") assert snapshot.num_qubits is not None def test_raw_properties_ref_when_tracker_provided( @@ -204,7 +207,8 @@ def test_returns_stable_info(self, fake_sampler): info = resolve_runtime_backend(fake_sampler) assert info is not None - assert info["provider"] == "qiskit-ibm-runtime" + # Provider should be physical (fake for fake backends) + assert info["provider"] in ("fake", "ibm_quantum", "aer", "local") assert info["backend_name"] assert info["backend_obj"] is not None assert info["primitive_type"] in ("sampler", "estimator") diff --git a/packages/devqubit-qiskit/src/devqubit_qiskit/adapter.py b/packages/devqubit-qiskit/src/devqubit_qiskit/adapter.py index 2bebaf1..b31dd3d 100644 --- a/packages/devqubit-qiskit/src/devqubit_qiskit/adapter.py +++ b/packages/devqubit-qiskit/src/devqubit_qiskit/adapter.py @@ -43,648 +43,44 @@ from __future__ import annotations -import hashlib import logging +import uuid from dataclasses import dataclass, field from typing import Any -from devqubit_engine.circuit.models import CircuitFormat from devqubit_engine.core.run import Run from devqubit_engine.uec.device import DeviceSnapshot from devqubit_engine.uec.envelope import ExecutionEnvelope -from devqubit_engine.uec.execution import ExecutionSnapshot -from devqubit_engine.uec.program import ( - ProgramArtifact, - ProgramSnapshot, - TranspilationInfo, -) -from devqubit_engine.uec.result import NormalizedCounts, ResultSnapshot -from devqubit_engine.uec.types import ( - ProgramRole, - ResultType, - TranspilationMode, -) +from devqubit_engine.uec.producer import ProducerInfo +from devqubit_engine.uec.program import ProgramSnapshot +from devqubit_engine.uec.result import ResultSnapshot from devqubit_engine.utils.serialization import to_jsonable from devqubit_engine.utils.time_utils import utc_now_iso -from devqubit_qiskit.results import ( - detect_result_type, - extract_result_metadata, - normalize_result_counts, +from devqubit_qiskit.circuits import ( + compute_circuit_hash, + materialize_circuits, + serialize_and_log_circuits, +) +from devqubit_qiskit.envelope import ( + create_execution_snapshot, + create_failure_result_snapshot, + create_program_snapshot, + create_result_snapshot, + detect_physical_provider, + finalize_envelope_with_result, + log_device_snapshot, +) +from devqubit_qiskit.utils import ( + extract_job_id, + get_adapter_version, + get_backend_name, + qiskit_version, ) -from devqubit_qiskit.serialization import QiskitCircuitSerializer -from devqubit_qiskit.snapshot import create_device_snapshot -from devqubit_qiskit.utils import extract_job_id, get_backend_name, qiskit_version -from qiskit import QuantumCircuit from qiskit.providers.backend import BackendV2 logger = logging.getLogger(__name__) -# Module-level serializer instance -_serializer = QiskitCircuitSerializer() - - -def _materialize_circuits(circuits: Any) -> tuple[list[Any], bool]: - """ - Materialize circuit inputs exactly once. - - Prevents consumption bugs when the user provides generators/iterators. - - Parameters - ---------- - circuits : Any - A QuantumCircuit, or an iterable of QuantumCircuit objects. - - Returns - ------- - circuit_list : list - List of circuit-like objects. - was_single : bool - True if the input was a single circuit-like object. - """ - if circuits is None: - return [], False - - # QuantumCircuit is iterable over instructions, so check explicitly - if isinstance(circuits, QuantumCircuit): - return [circuits], True - - if isinstance(circuits, (list, tuple)): - return list(circuits), False - - # Generic iterables (generator, iterator, etc.) - try: - return list(circuits), False - except TypeError: - # Not iterable -> treat as a single circuit-like payload - return [circuits], True - - -def _compute_circuit_hash(circuits: list[Any]) -> str | None: - """ - Compute a structure-only hash for Qiskit QuantumCircuit objects. - - Captures circuit structure (gates, qubits, classical bits) while - ignoring parameter values for deduplication purposes. - - Parameters - ---------- - circuits : list[Any] - List of Qiskit QuantumCircuit objects. - - Returns - ------- - str or None - Full SHA-256 digest in format ``sha256:``, or None if empty. - - Notes - ----- - The hash captures: - - Operation names (e.g., 'rx', 'cx', 'measure') - - Ordered qubit indices - - Ordered clbit indices (measurement wiring) - - Parameter arity (count only, not values) - - Classical condition presence - """ - if not circuits: - return None - - circuit_signatures: list[str] = [] - - for circuit in circuits: - try: - # Precompute indices for speed and stability - qubit_index = { - q: i for i, q in enumerate(getattr(circuit, "qubits", ()) or ()) - } - clbit_index = { - c: i for i, c in enumerate(getattr(circuit, "clbits", ()) or ()) - } - - op_sigs: list[str] = [] - for instr in getattr(circuit, "data", []) or []: - op = getattr(instr, "operation", None) - name = getattr(op, "name", None) - op_name = name if isinstance(name, str) and name else type(op).__name__ - - # Qubits / clbits in order (control-target order matters) - qs: list[int] = [] - for q in getattr(instr, "qubits", ()) or (): - if q in qubit_index: - qs.append(qubit_index[q]) - else: - # Fallback if circuit has unusual bit containers - qs.append(getattr(circuit.find_bit(q), "index", -1)) - cs: list[int] = [] - for c in getattr(instr, "clbits", ()) or (): - if c in clbit_index: - cs.append(clbit_index[c]) - else: - cs.append(getattr(circuit.find_bit(c), "index", -1)) - - # Parameter arity (count only) - params = getattr(op, "params", None) - parity = len(params) if isinstance(params, (list, tuple)) else 0 - - # Classical condition presence - cond = getattr(op, "condition", None) - has_cond = 1 if cond is not None else 0 - - op_sigs.append( - f"{op_name}|p{parity}|q{tuple(qs)}|c{tuple(cs)}|if{has_cond}" - ) - - circuit_signatures.append("||".join(op_sigs)) - - except Exception: - # Conservative fallback: avoid breaking tracking - circuit_signatures.append(str(circuit)[:500]) - - payload = "\n".join(circuit_signatures).encode("utf-8", errors="replace") - return f"sha256:{hashlib.sha256(payload).hexdigest()}" - - -def _circuits_to_text(circuits: list[Any]) -> str: - """ - Convert circuits to human-readable text diagrams. - - Parameters - ---------- - circuits : list - List of QuantumCircuit objects. - - Returns - ------- - str - Combined text diagram of all circuits. - """ - parts: list[str] = [] - - for i, circuit in enumerate(circuits): - if i > 0: - parts.append("") # Blank line between circuits - - name = getattr(circuit, "name", None) or f"circuit_{i}" - parts.append(f"[{i}] {name}") - - try: - diagram = circuit.draw(output="text", fold=80) - if hasattr(diagram, "single_string"): - parts.append(diagram.single_string()) - else: - parts.append(str(diagram)) - except Exception: - parts.append(str(circuit)) - - return "\n".join(parts) - - -def _serialize_and_log_circuits( - tracker: Run, - circuits: list[Any], - backend_name: str, - circuit_hash: str | None, -) -> list[ProgramArtifact]: - """ - Serialize and log circuits in multiple formats. - - Creates ProgramArtifact references for each circuit in each format, - properly handling multi-circuit batches. - - Parameters - ---------- - tracker : Run - Tracker instance. - circuits : list - List of QuantumCircuit objects. - backend_name : str - Backend name for metadata. - circuit_hash : str or None - Circuit structure hash. - - Returns - ------- - list of ProgramArtifact - References to logged program artifacts, one per format per circuit. - """ - artifacts: list[ProgramArtifact] = [] - meta = { - "backend_name": backend_name, - "qiskit_version": qiskit_version(), - "circuit_hash": circuit_hash, - "num_circuits": len(circuits), - } - - # Log circuits in QPY format (batch, lossless) - try: - qpy_data = _serializer.serialize(circuits, CircuitFormat.QPY) - ref = tracker.log_bytes( - kind="qiskit.qpy.circuits", - data=qpy_data.as_bytes(), - media_type="application/vnd.qiskit.qpy", - role="program", - meta={**meta, "security_note": "opaque_bytes_only"}, - ) - # QPY is a batch format - single artifact for all circuits - artifacts.append( - ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="qpy", - name="circuits_batch", - index=0, - ) - ) - except Exception as e: - logger.debug("Failed to serialize circuits to QPY: %s", e) - - # Log circuits in QASM3 format (per circuit, portable) - oq3_items: list[dict[str, Any]] = [] - for i, c in enumerate(circuits): - try: - qasm_data = _serializer.serialize(c, CircuitFormat.OPENQASM3, index=i) - qc_name = getattr(c, "name", None) or f"circuit_{i}" - oq3_items.append( - { - "source": qasm_data.as_text(), - "name": f"circuit_{i}:{qc_name}", - "index": i, - } - ) - except Exception: - continue - - if oq3_items: - oq3_result = tracker.log_openqasm3(oq3_items, name="circuits", meta=meta) - # Generate ProgramArtifact per circuit, not just the first one - items = oq3_result.get("items", []) - for item in items: - ref = item.get("raw_ref") - if ref: - item_index = item.get("index", 0) - item_name = item.get("name", f"circuit_{item_index}") - artifacts.append( - ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="openqasm3", - name=item_name, - index=item_index, - ) - ) - - # Log circuit diagrams (human-readable text) - try: - diagram_text = _circuits_to_text(circuits) - ref = tracker.log_bytes( - kind="qiskit.circuits.diagram", - data=diagram_text.encode("utf-8"), - media_type="text/plain; charset=utf-8", - role="program", - meta={"num_circuits": len(circuits)}, - ) - artifacts.append( - ProgramArtifact( - ref=ref, - role=ProgramRole.LOGICAL, - format="diagram", - name="circuits", - index=0, - ) - ) - except Exception: - pass # Diagram logging is best-effort - - return artifacts - - -def _create_program_snapshot( - program_artifacts: list[ProgramArtifact], - circuit_hash: str | None, - num_circuits: int, -) -> ProgramSnapshot: - """ - Create a ProgramSnapshot from logged artifacts. - - Parameters - ---------- - program_artifacts : list of ProgramArtifact - References to logged circuit artifacts. - circuit_hash : str or None - Circuit structure hash. - num_circuits : int - Number of circuits in the program. - - Returns - ------- - ProgramSnapshot - Program snapshot with artifact references. - """ - return ProgramSnapshot( - logical=program_artifacts, - physical=[], # Base Qiskit adapter doesn't transpile - program_hash=circuit_hash, - num_circuits=num_circuits, - ) - - -def _create_execution_snapshot( - submitted_at: str, - shots: int | None, - exec_count: int, - job_ids: list[str] | None, - options: dict[str, Any], -) -> ExecutionSnapshot: - """ - Create an ExecutionSnapshot. - - Parameters - ---------- - submitted_at : str - ISO timestamp of submission. - shots : int or None - Number of shots requested. - exec_count : int - Execution count. - job_ids : list of str or None - Job IDs if available. - options : dict - Execution options (args, kwargs). - - Returns - ------- - ExecutionSnapshot - Execution metadata snapshot. - """ - return ExecutionSnapshot( - submitted_at=submitted_at, - shots=shots, - execution_count=exec_count, - job_ids=job_ids or [], - transpilation=TranspilationInfo( - mode=TranspilationMode.MANUAL, - transpiled_by="user", - ), - options=options, - sdk="qiskit", - ) - - -def _create_result_snapshot( - tracker: Run, - backend_name: str, - result: Any, -) -> ResultSnapshot: - """ - Create a ResultSnapshot from a Qiskit Result object. - - Detects result type and extracts appropriate normalized data. - - Parameters - ---------- - tracker : Run - Tracker instance. - backend_name : str - Backend name. - result : Any - Qiskit Result object. - - Returns - ------- - ResultSnapshot - Structured result snapshot. - """ - # Handle None result - if result is None: - return ResultSnapshot( - result_type=ResultType.OTHER, - raw_result_ref=None, - counts=[], - num_experiments=0, - success=False, - error_message="Result is None", - metadata={"backend_name": backend_name}, - ) - - # Detect result type - result_type = detect_result_type(result) - - # Serialize full result - try: - if hasattr(result, "to_dict") and callable(result.to_dict): - result_dict = result.to_dict() - else: - result_dict = result - payload = to_jsonable(result_dict) - except Exception as e: - logger.debug("Failed to serialize result to dict: %s", e) - payload = {"repr": repr(result)[:2000]} - - raw_result_ref = tracker.log_json( - name="qiskit.result", - obj=payload, - role="results", - kind="result.qiskit.result_json", - ) - - # Extract and log measurement counts - counts_data = normalize_result_counts(result) - normalized_counts: list[NormalizedCounts] = [] - - if counts_data.get("experiments"): - tracker.log_json( - name="counts", - obj=counts_data, - role="results", - kind="result.counts.json", - ) - - # Build normalized counts list - for exp in counts_data["experiments"]: - normalized_counts.append( - NormalizedCounts( - circuit_index=exp.get("index", 0), - counts=exp.get("counts", {}), - shots=exp.get("shots"), - name=exp.get("name"), - ) - ) - - # Extract metadata - meta = extract_result_metadata(result) - success = meta.get("success", True) - - # Build result snapshot - return ResultSnapshot( - result_type=result_type, - raw_result_ref=raw_result_ref, - counts=normalized_counts, - num_experiments=len(counts_data.get("experiments", [])), - success=success, - metadata={ - "backend_name": backend_name, - **meta, - }, - ) - - -def _finalize_envelope_with_result( - tracker: Run, - envelope: ExecutionEnvelope, - result_snapshot: ResultSnapshot, -) -> None: - """ - Finalize envelope with result and log as artifact. - - Parameters - ---------- - tracker : Run - Tracker instance. - envelope : ExecutionEnvelope - Envelope to finalize. - result_snapshot : ResultSnapshot - Result to add to envelope. - - Raises - ------ - ValueError - If envelope is None. - """ - if envelope is None: - raise ValueError("Cannot finalize None envelope") - - if result_snapshot is None: - logger.warning("Finalizing envelope with None result_snapshot") - - # Add result to envelope - envelope.result = result_snapshot - - # Set completion time - if envelope.execution is not None: - envelope.execution.completed_at = utc_now_iso() - - # Validate and log envelope using tracker's canonical method - tracker.log_envelope(envelope=envelope) - - -def _create_minimal_device_snapshot( - backend: Any, - captured_at: str, - error_msg: str | None = None, -) -> DeviceSnapshot: - """ - Create a minimal DeviceSnapshot when full snapshot creation fails. - - Ensures envelope can always be completed even if backend introspection - fails due to network issues or unsupported backend types. - - Parameters - ---------- - backend : Any - Qiskit backend (may be partially functional). - captured_at : str - ISO timestamp. - error_msg : str, optional - Error message explaining why full snapshot failed. - - Returns - ------- - DeviceSnapshot - Minimal snapshot with available information. - """ - backend_name = get_backend_name(backend) - - # Try to determine backend type - backend_type = "unknown" - name_lower = backend_name.lower() - type_lower = type(backend).__name__.lower() - - if any(s in name_lower or s in type_lower for s in ("sim", "emulator", "fake")): - backend_type = "simulator" - elif any(s in name_lower for s in ("ibm_", "ionq", "rigetti", "oqc")): - backend_type = "hardware" - - # Try to get num_qubits - num_qubits = None - try: - num_qubits = backend.num_qubits - except Exception: - pass - - snapshot = DeviceSnapshot( - captured_at=captured_at, - backend_name=backend_name, - backend_type=backend_type, - provider="qiskit", - num_qubits=num_qubits, - sdk_versions={"qiskit": qiskit_version()}, - ) - - if error_msg: - logger.warning( - "Created minimal device snapshot for %s: %s", - backend_name, - error_msg, - ) - - return snapshot - - -def _log_device_snapshot(backend: Any, tracker: Run) -> DeviceSnapshot: - """ - Log device snapshot with fallback to minimal snapshot on failure. - - Logs both the snapshot summary and raw properties as separate artifacts - for complete backend state capture. - - Parameters - ---------- - backend : Any - Qiskit backend. - tracker : Run - Tracker instance. - - Returns - ------- - DeviceSnapshot - Created device snapshot (full or minimal). - """ - backend_name = get_backend_name(backend) - captured_at = utc_now_iso() - - try: - # Create snapshot with tracker for raw_properties logging - snapshot = create_device_snapshot( - backend, - refresh_properties=True, - tracker=tracker, - ) - except Exception as e: - # Generate minimal snapshot on failure instead of propagating - logger.warning( - "Full device snapshot failed for %s: %s. Using minimal snapshot.", - backend_name, - e, - ) - snapshot = _create_minimal_device_snapshot( - backend, captured_at, error_msg=str(e) - ) - - # Update tracker record with summary (for querying and fingerprinting) - tracker.record["device_snapshot"] = { - "sdk": "qiskit", - "backend_name": backend_name, - "backend_type": snapshot.backend_type, - "provider": snapshot.provider, - "captured_at": snapshot.captured_at, - "num_qubits": snapshot.num_qubits, - "calibration_summary": snapshot.get_calibration_summary(), - } - - logger.debug("Logged device snapshot for %s", backend_name) - - return snapshot - @dataclass class TrackedJob: @@ -734,6 +130,10 @@ def result(self, *args: Any, **kwargs: Any) -> Any: """ Retrieve job result and log artifacts. + Always creates an envelope - even when job.result() fails. + This is a UEC requirement: envelope must exist for + failure cases to enable debugging and telemetry. + Idempotent: calling result() multiple times will only log once. Parameters @@ -747,16 +147,30 @@ def result(self, *args: Any, **kwargs: Any) -> Any: ------- Result Qiskit Result object. - """ - result = self.job.result(*args, **kwargs) - # Idempotent result logging - only log once + Raises + ------ + Exception + Re-raises any exception from job.result() after logging + the failure envelope. + """ + # Try to get result - may raise exception + try: + result = self.job.result(*args, **kwargs) + except Exception as exc: + # ALWAYS create failure envelope before re-raising + if self.should_log_results and not self._result_logged: + self._result_logged = True + self._log_failure(exc) + raise # Re-raise original exception with original traceback + + # Happy path - log successful result if self.should_log_results and not self._result_logged: self._result_logged = True try: # Create result snapshot - self.result_snapshot = _create_result_snapshot( + self.result_snapshot = create_result_snapshot( self.tracker, self.backend_name, result, @@ -764,37 +178,32 @@ def result(self, *args: Any, **kwargs: Any) -> Any: # Finalize envelope with result if self.envelope is not None and self.result_snapshot is not None: - _finalize_envelope_with_result( + finalize_envelope_with_result( self.tracker, self.envelope, self.result_snapshot, ) - # Update tracker record (used by fingerprint computation) + # Update tracker record if self.result_snapshot is not None: - result_type_str = ( - self.result_snapshot.result_type.value - if hasattr(self.result_snapshot.result_type, "value") - else str(self.result_snapshot.result_type) - ) self.tracker.record["results"] = { "completed_at": utc_now_iso(), "backend_name": self.backend_name, - "num_experiments": self.result_snapshot.num_experiments, - "result_type": result_type_str, + "success": self.result_snapshot.success, + "status": self.result_snapshot.status, + "num_items": len(self.result_snapshot.items), **self.result_snapshot.metadata, } logger.debug("Logged results on %s", self.backend_name) except Exception as e: - # Log error but don't fail - result retrieval should always succeed + # Log error but don't fail - result retrieval should succeed logger.warning( "Failed to log results for %s: %s", self.backend_name, e, ) - # Record error in tracker for visibility self.tracker.record.setdefault("warnings", []).append( { "type": "result_logging_failed", @@ -805,6 +214,54 @@ def result(self, *args: Any, **kwargs: Any) -> Any: return result + def _log_failure(self, exc: Exception) -> None: + """ + Log failure envelope when job.result() raises an exception. + + Parameters + ---------- + exc : Exception + The exception that was raised. + """ + try: + # Create failure result snapshot + self.result_snapshot = create_failure_result_snapshot( + exception=exc, + backend_name=self.backend_name, + ) + + # Finalize envelope with failure result + if self.envelope is not None: + finalize_envelope_with_result( + self.tracker, + self.envelope, + self.result_snapshot, + ) + + # Update tracker record with failure info + self.tracker.record["results"] = { + "completed_at": utc_now_iso(), + "backend_name": self.backend_name, + "success": False, + "status": "failed", + "error_type": type(exc).__name__, + "error_message": str(exc)[:500], + } + + logger.debug( + "Logged failure envelope for %s: %s", + self.backend_name, + type(exc).__name__, + ) + + except Exception as log_error: + # Last resort - don't let logging failure mask original error + logger.error( + "Failed to log failure envelope for %s: %s", + self.backend_name, + log_error, + ) + def __getattr__(self, name: str) -> Any: """Delegate attribute access to wrapped job.""" return getattr(self.job, name) @@ -906,7 +363,7 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: submitted_at = utc_now_iso() # Materialize once to avoid consuming generators during logging - circuit_list, was_single = _materialize_circuits(circuits) + circuit_list, was_single = materialize_circuits(circuits) # Payload for backend.run(): single circuit if user gave single, else list run_payload: Any = ( @@ -918,7 +375,7 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: exec_count = self._execution_count # Compute circuit hash for structure detection - circuit_hash = _compute_circuit_hash(circuit_list) + circuit_hash = compute_circuit_hash(circuit_list) is_new_circuit = circuit_hash and circuit_hash not in self._seen_circuit_hashes if circuit_hash: self._seen_circuit_hashes.add(circuit_hash) @@ -961,14 +418,18 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: envelope=None, ) + # Detect physical provider (not SDK) + detected_provider = detect_physical_provider(self.backend) + # Set tags self.tracker.set_tag("backend_name", backend_name) - self.tracker.set_tag("provider", "qiskit") - self.tracker.set_tag("adapter", "qiskit") + self.tracker.set_tag("sdk", "qiskit") + self.tracker.set_tag("adapter", "devqubit-qiskit") + self.tracker.set_tag("provider", detected_provider) # Log device snapshot (once per run) if not self._snapshot_logged: - self.device_snapshot = _log_device_snapshot(self.backend, self.tracker) + self.device_snapshot = log_device_snapshot(self.backend, self.tracker) self._snapshot_logged = True # Build program snapshot @@ -988,7 +449,7 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: ) # Log circuits - program_artifacts = _serialize_and_log_circuits( + program_artifacts = serialize_and_log_circuits( self.tracker, circuit_list, backend_name, @@ -999,7 +460,7 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: self._logged_circuit_hashes.add(circuit_hash) # Create program snapshot - program_snapshot = _create_program_snapshot( + program_snapshot = create_program_snapshot( program_artifacts, circuit_hash, len(circuit_list), @@ -1013,7 +474,8 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: self.tracker.record["backend"] = { "name": backend_name, "type": self.backend.__class__.__name__, - "provider": "qiskit", + "provider": detected_provider, + "sdk": "qiskit", } self._logged_execution_count += 1 @@ -1024,7 +486,7 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: # Build ExecutionSnapshot shots = kwargs.get("shots") - execution_snapshot = _create_execution_snapshot( + execution_snapshot = create_execution_snapshot( submitted_at=submitted_at, shots=int(shots) if shots is not None else None, exec_count=exec_count, @@ -1075,14 +537,31 @@ def run(self, circuits: Any, *args: Any, **kwargs: Any) -> TrackedJob: num_circuits=len(circuit_list), ) + # Create ProducerInfo for SDK stack tracking + producer = ProducerInfo.create( + adapter="devqubit-qiskit", + adapter_version=get_adapter_version(), + sdk="qiskit", + sdk_version=qiskit_version(), + frontends=["qiskit"], + ) + + # Create envelope with pending result (will be updated in result()) + pending_result = ResultSnapshot( + success=False, + status="failed", # Will be updated when result() completes + items=[], + metadata={"state": "pending"}, + ) + envelope = ExecutionEnvelope( - schema_version="devqubit.envelope/0.1", - adapter="qiskit", + envelope_id=uuid.uuid4().hex[:26], created_at=utc_now_iso(), + producer=producer, + result=pending_result, device=self.device_snapshot, program=program_snapshot, execution=execution_snapshot, - result=None, # Will be filled when result() is called ) # Update stats @@ -1178,12 +657,13 @@ def describe_executor(self, executor: Any) -> dict[str, Any]: Returns ------- dict - Backend description with keys: name, type, provider. + Backend description with keys: name, type, provider, sdk. """ return { "name": get_backend_name(executor), "type": executor.__class__.__name__, - "provider": "qiskit", + "provider": detect_physical_provider(executor), + "sdk": "qiskit", } def wrap_executor( diff --git a/packages/devqubit-qiskit/src/devqubit_qiskit/circuits.py b/packages/devqubit-qiskit/src/devqubit_qiskit/circuits.py new file mode 100644 index 0000000..8136bfa --- /dev/null +++ b/packages/devqubit-qiskit/src/devqubit_qiskit/circuits.py @@ -0,0 +1,303 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Circuit handling utilities for Qiskit adapter. + +This module provides functions for materializing, hashing, serializing, +and logging Qiskit QuantumCircuit objects. +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Any + +from devqubit_engine.circuit.models import CircuitFormat +from devqubit_engine.core.run import Run +from devqubit_engine.uec.program import ProgramArtifact +from devqubit_engine.uec.types import ProgramRole +from devqubit_qiskit.serialization import QiskitCircuitSerializer +from devqubit_qiskit.utils import qiskit_version +from qiskit import QuantumCircuit + + +logger = logging.getLogger(__name__) + +# Module-level serializer instance +_serializer = QiskitCircuitSerializer() + + +def materialize_circuits(circuits: Any) -> tuple[list[Any], bool]: + """ + Materialize circuit inputs exactly once. + + Prevents consumption bugs when the user provides generators/iterators. + + Parameters + ---------- + circuits : Any + A QuantumCircuit, or an iterable of QuantumCircuit objects. + + Returns + ------- + circuit_list : list + List of circuit-like objects. + was_single : bool + True if the input was a single circuit-like object. + """ + if circuits is None: + return [], False + + # QuantumCircuit is iterable over instructions, so check explicitly + if isinstance(circuits, QuantumCircuit): + return [circuits], True + + if isinstance(circuits, (list, tuple)): + return list(circuits), False + + # Generic iterables (generator, iterator, etc.) + try: + return list(circuits), False + except TypeError: + # Not iterable -> treat as a single circuit-like payload + return [circuits], True + + +def compute_circuit_hash(circuits: list[Any]) -> str | None: + """ + Compute a structure-only hash for Qiskit QuantumCircuit objects. + + Captures circuit structure (gates, qubits, classical bits) while + ignoring parameter values for deduplication purposes. + + Parameters + ---------- + circuits : list[Any] + List of Qiskit QuantumCircuit objects. + + Returns + ------- + str or None + Full SHA-256 digest in format ``sha256:``, or None if empty. + + Notes + ----- + The hash captures: + - Operation names (e.g., 'rx', 'cx', 'measure') + - Ordered qubit indices + - Ordered clbit indices (measurement wiring) + - Parameter arity (count only, not values) + - Classical condition presence + """ + if not circuits: + return None + + circuit_signatures: list[str] = [] + + for circuit in circuits: + try: + # Precompute indices for speed and stability + qubit_index = { + q: i for i, q in enumerate(getattr(circuit, "qubits", ()) or ()) + } + clbit_index = { + c: i for i, c in enumerate(getattr(circuit, "clbits", ()) or ()) + } + + op_sigs: list[str] = [] + for instr in getattr(circuit, "data", []) or []: + op = getattr(instr, "operation", None) + name = getattr(op, "name", None) + op_name = name if isinstance(name, str) and name else type(op).__name__ + + # Qubits / clbits in order (control-target order matters) + qs: list[int] = [] + for q in getattr(instr, "qubits", ()) or (): + if q in qubit_index: + qs.append(qubit_index[q]) + else: + # Fallback if circuit has unusual bit containers + qs.append(getattr(circuit.find_bit(q), "index", -1)) + cs: list[int] = [] + for c in getattr(instr, "clbits", ()) or (): + if c in clbit_index: + cs.append(clbit_index[c]) + else: + cs.append(getattr(circuit.find_bit(c), "index", -1)) + + # Parameter arity (count only) + params = getattr(op, "params", None) + parity = len(params) if isinstance(params, (list, tuple)) else 0 + + # Classical condition presence + cond = getattr(op, "condition", None) + has_cond = 1 if cond is not None else 0 + + op_sigs.append( + f"{op_name}|p{parity}|q{tuple(qs)}|c{tuple(cs)}|if{has_cond}" + ) + + circuit_signatures.append("||".join(op_sigs)) + + except Exception: + # Conservative fallback: avoid breaking tracking + circuit_signatures.append(str(circuit)[:500]) + + payload = "\n".join(circuit_signatures).encode("utf-8", errors="replace") + return f"sha256:{hashlib.sha256(payload).hexdigest()}" + + +def circuits_to_text(circuits: list[Any]) -> str: + """ + Convert circuits to human-readable text diagrams. + + Parameters + ---------- + circuits : list + List of QuantumCircuit objects. + + Returns + ------- + str + Combined text diagram of all circuits. + """ + parts: list[str] = [] + + for i, circuit in enumerate(circuits): + if i > 0: + parts.append("") # Blank line between circuits + + name = getattr(circuit, "name", None) or f"circuit_{i}" + parts.append(f"[{i}] {name}") + + try: + diagram = circuit.draw(output="text", fold=80) + if hasattr(diagram, "single_string"): + parts.append(diagram.single_string()) + else: + parts.append(str(diagram)) + except Exception: + parts.append(str(circuit)) + + return "\n".join(parts) + + +def serialize_and_log_circuits( + tracker: Run, + circuits: list[Any], + backend_name: str, + circuit_hash: str | None, +) -> list[ProgramArtifact]: + """ + Serialize and log circuits in multiple formats. + + Creates ProgramArtifact references for each circuit in each format, + properly handling multi-circuit batches. + + Parameters + ---------- + tracker : Run + Tracker instance. + circuits : list + List of QuantumCircuit objects. + backend_name : str + Backend name for metadata. + circuit_hash : str or None + Circuit structure hash. + + Returns + ------- + list of ProgramArtifact + References to logged program artifacts, one per format per circuit. + """ + artifacts: list[ProgramArtifact] = [] + meta = { + "backend_name": backend_name, + "qiskit_version": qiskit_version(), + "circuit_hash": circuit_hash, + "num_circuits": len(circuits), + } + + # Log circuits in QPY format (batch, lossless) + try: + qpy_data = _serializer.serialize(circuits, CircuitFormat.QPY) + ref = tracker.log_bytes( + kind="qiskit.qpy.circuits", + data=qpy_data.as_bytes(), + media_type="application/vnd.qiskit.qpy", + role="program", + meta={**meta, "security_note": "opaque_bytes_only"}, + ) + # QPY is a batch format - single artifact for all circuits + artifacts.append( + ProgramArtifact( + ref=ref, + role=ProgramRole.LOGICAL, + format="qpy", + name="circuits_batch", + index=0, + ) + ) + except Exception as e: + logger.debug("Failed to serialize circuits to QPY: %s", e) + + # Log circuits in QASM3 format (per circuit, portable) + oq3_items: list[dict[str, Any]] = [] + for i, c in enumerate(circuits): + try: + qasm_data = _serializer.serialize(c, CircuitFormat.OPENQASM3, index=i) + qc_name = getattr(c, "name", None) or f"circuit_{i}" + oq3_items.append( + { + "source": qasm_data.as_text(), + "name": f"circuit_{i}:{qc_name}", + "index": i, + } + ) + except Exception: + continue + + if oq3_items: + oq3_result = tracker.log_openqasm3(oq3_items, name="circuits", meta=meta) + # Generate ProgramArtifact per circuit, not just the first one + items = oq3_result.get("items", []) + for item in items: + ref = item.get("raw_ref") + if ref: + item_index = item.get("index", 0) + item_name = item.get("name", f"circuit_{item_index}") + artifacts.append( + ProgramArtifact( + ref=ref, + role=ProgramRole.LOGICAL, + format="openqasm3", + name=item_name, + index=item_index, + ) + ) + + # Log circuit diagrams (human-readable text) + try: + diagram_text = circuits_to_text(circuits) + ref = tracker.log_bytes( + kind="qiskit.circuits.diagram", + data=diagram_text.encode("utf-8"), + media_type="text/plain; charset=utf-8", + role="program", + meta={"num_circuits": len(circuits)}, + ) + artifacts.append( + ProgramArtifact( + ref=ref, + role=ProgramRole.LOGICAL, + format="diagram", + name="circuits", + index=0, + ) + ) + except Exception: + pass # Diagram logging is best-effort + + return artifacts diff --git a/packages/devqubit-qiskit/src/devqubit_qiskit/envelope.py b/packages/devqubit-qiskit/src/devqubit_qiskit/envelope.py new file mode 100644 index 0000000..35a2987 --- /dev/null +++ b/packages/devqubit-qiskit/src/devqubit_qiskit/envelope.py @@ -0,0 +1,443 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 devqubit + +""" +Envelope and snapshot utilities for Qiskit adapter. + +This module provides functions for creating UEC snapshots and +managing ExecutionEnvelope lifecycle. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from devqubit_engine.core.run import Run +from devqubit_engine.uec.device import DeviceSnapshot +from devqubit_engine.uec.envelope import ExecutionEnvelope +from devqubit_engine.uec.execution import ExecutionSnapshot +from devqubit_engine.uec.program import ( + ProgramArtifact, + ProgramSnapshot, + TranspilationInfo, +) +from devqubit_engine.uec.result import ( + CountsFormat, + ResultError, + ResultItem, + ResultSnapshot, +) +from devqubit_engine.uec.types import ArtifactRef, TranspilationMode +from devqubit_engine.utils.serialization import to_jsonable +from devqubit_engine.utils.time_utils import utc_now_iso +from devqubit_qiskit.results import extract_result_metadata, normalize_result_counts +from devqubit_qiskit.snapshot import create_device_snapshot +from devqubit_qiskit.utils import get_backend_name, qiskit_version + + +logger = logging.getLogger(__name__) + + +def detect_physical_provider(backend: Any) -> str: + """ + Detect physical provider from backend (not SDK). + + UEC requires provider to be the physical backend provider, + not the SDK name. SDK goes in producer.frontends[]. + + Parameters + ---------- + backend : Any + Qiskit backend instance. + + Returns + ------- + str + Physical provider: "ibm_quantum", "aer", "fake", or "local". + """ + module_name = type(backend).__module__.lower() + backend_name = get_backend_name(backend).lower() + + if "ibm" in module_name or "ibm_" in backend_name: + return "ibm_quantum" + if "qiskit_aer" in module_name or "aer" in module_name: + return "aer" + if "fake" in module_name: + return "fake" + return "local" + + +def create_program_snapshot( + program_artifacts: list[ProgramArtifact], + circuit_hash: str | None, + num_circuits: int, +) -> ProgramSnapshot: + """ + Create a ProgramSnapshot from logged artifacts. + + Parameters + ---------- + program_artifacts : list of ProgramArtifact + References to logged circuit artifacts. + circuit_hash : str or None + Circuit structure hash. + num_circuits : int + Number of circuits in the program. + + Returns + ------- + ProgramSnapshot + Program snapshot with artifact references. + """ + return ProgramSnapshot( + logical=program_artifacts, + physical=[], # Base Qiskit adapter doesn't transpile + program_hash=circuit_hash, + num_circuits=num_circuits, + ) + + +def create_execution_snapshot( + submitted_at: str, + shots: int | None, + exec_count: int, + job_ids: list[str] | None, + options: dict[str, Any], +) -> ExecutionSnapshot: + """ + Create an ExecutionSnapshot. + + Parameters + ---------- + submitted_at : str + ISO timestamp of submission. + shots : int or None + Number of shots requested. + exec_count : int + Execution count. + job_ids : list of str or None + Job IDs if available. + options : dict + Execution options (args, kwargs). + + Returns + ------- + ExecutionSnapshot + Execution metadata snapshot. + """ + return ExecutionSnapshot( + submitted_at=submitted_at, + shots=shots, + execution_count=exec_count, + job_ids=job_ids or [], + transpilation=TranspilationInfo( + mode=TranspilationMode.MANUAL, + transpiled_by="user", + ), + options=options, + sdk="qiskit", + ) + + +def create_result_snapshot( + tracker: Run, + backend_name: str, + result: Any, +) -> ResultSnapshot: + """ + Create a ResultSnapshot from a Qiskit Result object. + + Uses UEC 1.0 structure with items[], CountsFormat for + cross-SDK comparability. + + Parameters + ---------- + tracker : Run + Tracker instance. + backend_name : str + Backend name. + result : Any + Qiskit Result object. + + Returns + ------- + ResultSnapshot + Structured result snapshot with items[]. + """ + # Handle None result + if result is None: + return ResultSnapshot( + success=False, + status="failed", + items=[], + error=ResultError(type="NullResult", message="Result is None"), + metadata={"backend_name": backend_name}, + ) + + # Serialize full result as artifact + raw_result_ref: ArtifactRef | None = None + try: + if hasattr(result, "to_dict") and callable(result.to_dict): + result_dict = result.to_dict() + else: + result_dict = result + payload = to_jsonable(result_dict) + raw_result_ref = tracker.log_json( + name="qiskit.result", + obj=payload, + role="results", + kind="result.qiskit.result_json", + ) + except Exception as e: + logger.debug("Failed to serialize result to dict: %s", e) + + # Extract measurement counts + counts_data = normalize_result_counts(result) + experiments = counts_data.get("experiments", []) + + # Log counts as separate artifact + if experiments: + tracker.log_json( + name="counts", + obj=counts_data, + role="results", + kind="result.counts.json", + ) + + # Qiskit counts format metadata + # Qiskit uses little-endian (cbit[0] on right) = UEC canonical + counts_format = CountsFormat( + source_sdk="qiskit", + source_key_format="qiskit_little_endian", + bit_order="cbit0_right", # Qiskit native = UEC canonical + transformed=False, # No transformation needed + ) + + # Build ResultItem list + items: list[ResultItem] = [] + for exp in experiments: + counts = exp.get("counts", {}) + shots = exp.get("shots") + item_index = exp.get("index", 0) + + # Ensure counts keys are strings and values are ints + normalized_counts = {str(k): int(v) for k, v in counts.items()} + + items.append( + ResultItem( + item_index=item_index, + success=True, + counts={ + "counts": normalized_counts, + "shots": shots, + "format": counts_format.to_dict(), + }, + ) + ) + + # Extract metadata for status + meta = extract_result_metadata(result) + success = meta.get("success", True) + status = "completed" if success else "failed" + + return ResultSnapshot( + success=success, + status=status, + items=items, + raw_result_ref=raw_result_ref, + metadata={ + "backend_name": backend_name, + "num_experiments": len(experiments), + **meta, + }, + ) + + +def create_failure_result_snapshot( + exception: BaseException, + backend_name: str, +) -> ResultSnapshot: + """ + Create a ResultSnapshot for a failed execution. + + Used when job.result() raises an exception. Ensures envelope + is always created even on failures (UEC requirement). + + Parameters + ---------- + exception : BaseException + The exception that caused the failure. + backend_name : str + Backend name for metadata. + + Returns + ------- + ResultSnapshot + Failed result snapshot with error details. + """ + return ResultSnapshot.create_failed( + exception=exception, + metadata={"backend_name": backend_name}, + ) + + +def finalize_envelope_with_result( + tracker: Run, + envelope: ExecutionEnvelope, + result_snapshot: ResultSnapshot, +) -> None: + """ + Finalize envelope with result and log as artifact. + + Parameters + ---------- + tracker : Run + Tracker instance. + envelope : ExecutionEnvelope + Envelope to finalize. + result_snapshot : ResultSnapshot + Result to add to envelope. + + Raises + ------ + ValueError + If envelope is None. + """ + if envelope is None: + raise ValueError("Cannot finalize None envelope") + + if result_snapshot is None: + logger.warning("Finalizing envelope with None result_snapshot") + + # Add result to envelope + envelope.result = result_snapshot + + # Set completion time + if envelope.execution is not None: + envelope.execution.completed_at = utc_now_iso() + + # Validate and log envelope using tracker's canonical method + tracker.log_envelope(envelope=envelope) + + +def create_minimal_device_snapshot( + backend: Any, + captured_at: str, + error_msg: str | None = None, +) -> DeviceSnapshot: + """ + Create a minimal DeviceSnapshot when full snapshot creation fails. + + Ensures envelope can always be completed even if backend introspection + fails due to network issues or unsupported backend types. + + Parameters + ---------- + backend : Any + Qiskit backend (may be partially functional). + captured_at : str + ISO timestamp. + error_msg : str, optional + Error message explaining why full snapshot failed. + + Returns + ------- + DeviceSnapshot + Minimal snapshot with available information. + """ + backend_name = get_backend_name(backend) + name_lower = backend_name.lower() + type_lower = type(backend).__name__.lower() + + # Determine backend type - default to simulator (safer fallback) + # Schema allows: "hardware", "simulator", "emulator" + backend_type = "simulator" + if any(s in name_lower for s in ("ibm_", "ionq", "rigetti", "oqc")): + backend_type = "hardware" + elif any(s in name_lower or s in type_lower for s in ("sim", "emulator", "fake")): + backend_type = "simulator" + + # Detect physical provider (not SDK) + provider = detect_physical_provider(backend) + + # Try to get num_qubits + num_qubits = None + try: + num_qubits = backend.num_qubits + except Exception: + pass + + snapshot = DeviceSnapshot( + captured_at=captured_at, + backend_name=backend_name, + backend_type=backend_type, + provider=provider, + num_qubits=num_qubits, + sdk_versions={"qiskit": qiskit_version()}, + ) + + if error_msg: + logger.warning( + "Created minimal device snapshot for %s: %s", + backend_name, + error_msg, + ) + + return snapshot + + +def log_device_snapshot(backend: Any, tracker: Run) -> DeviceSnapshot: + """ + Log device snapshot with fallback to minimal snapshot on failure. + + Logs both the snapshot summary and raw properties as separate artifacts + for complete backend state capture. + + Parameters + ---------- + backend : Any + Qiskit backend. + tracker : Run + Tracker instance. + + Returns + ------- + DeviceSnapshot + Created device snapshot (full or minimal). + """ + backend_name = get_backend_name(backend) + captured_at = utc_now_iso() + + try: + # Create snapshot with tracker for raw_properties logging + snapshot = create_device_snapshot( + backend, + refresh_properties=True, + tracker=tracker, + ) + except Exception as e: + # Generate minimal snapshot on failure instead of propagating + logger.warning( + "Full device snapshot failed for %s: %s. Using minimal snapshot.", + backend_name, + e, + ) + snapshot = create_minimal_device_snapshot( + backend, captured_at, error_msg=str(e) + ) + + # Update tracker record with summary (for querying and fingerprinting) + tracker.record["device_snapshot"] = { + "sdk": "qiskit", + "backend_name": backend_name, + "backend_type": snapshot.backend_type, + "provider": snapshot.provider, + "captured_at": snapshot.captured_at, + "num_qubits": snapshot.num_qubits, + "calibration_summary": snapshot.get_calibration_summary(), + } + + logger.debug("Logged device snapshot for %s", backend_name) + + return snapshot diff --git a/packages/devqubit-qiskit/src/devqubit_qiskit/snapshot.py b/packages/devqubit-qiskit/src/devqubit_qiskit/snapshot.py index 3bf5c32..c66debf 100644 --- a/packages/devqubit-qiskit/src/devqubit_qiskit/snapshot.py +++ b/packages/devqubit-qiskit/src/devqubit_qiskit/snapshot.py @@ -281,7 +281,10 @@ def _detect_backend_type(backend: Any) -> str: def _detect_provider(backend: Any) -> str: """ - Detect the provider for a Qiskit backend. + Detect the physical provider for a Qiskit backend. + + This returns the physical backend provider (ibm_quantum, aer, etc.), + not the SDK. The SDK (qiskit) goes in producer.frontends[]. Parameters ---------- @@ -291,18 +294,19 @@ def _detect_provider(backend: Any) -> str: Returns ------- str - Provider identifier. + Provider identifier: "ibm_quantum", "aer", "fake", or "local". """ module_name = type(backend).__module__.lower() + backend_name = get_backend_name(backend).lower() + if "ibm" in module_name or "ibm_" in backend_name: + return "ibm_quantum" if "qiskit_aer" in module_name or "aer" in module_name: return "aer" if "fake" in module_name: return "fake" - if "ibm" in module_name: - return "ibm_quantum" - return "qiskit" + return "local" def _get_sdk_versions() -> dict[str, str]: diff --git a/packages/devqubit-qiskit/src/devqubit_qiskit/utils.py b/packages/devqubit-qiskit/src/devqubit_qiskit/utils.py index 9fd649a..7802f27 100644 --- a/packages/devqubit-qiskit/src/devqubit_qiskit/utils.py +++ b/packages/devqubit-qiskit/src/devqubit_qiskit/utils.py @@ -27,6 +27,16 @@ def qiskit_version() -> str: return getattr(qiskit, "__version__", "unknown") +def get_adapter_version() -> str: + """Get adapter version dynamically from package metadata.""" + try: + from importlib.metadata import version + + return version("devqubit-qiskit") + except Exception: + return "unknown" + + def get_backend_name(backend: Any) -> str: """ Extract backend name from a Qiskit backend instance. diff --git a/packages/devqubit-qiskit/tests/test_qiskit_adapter.py b/packages/devqubit-qiskit/tests/test_qiskit_adapter.py index 5dccf19..0c7c8c2 100644 --- a/packages/devqubit-qiskit/tests/test_qiskit_adapter.py +++ b/packages/devqubit-qiskit/tests/test_qiskit_adapter.py @@ -12,8 +12,10 @@ QiskitAdapter, TrackedBackend, TrackedJob, - _compute_circuit_hash, - _materialize_circuits, +) +from devqubit_qiskit.circuits import ( + compute_circuit_hash, + materialize_circuits, ) from devqubit_qiskit.serialization import ( LoadedCircuitBatch, @@ -83,13 +85,13 @@ class TestMaterializeCircuits: def test_single_circuit(self, bell_circuit): """Single circuit returns (list, was_single=True).""" - result, was_single = _materialize_circuits(bell_circuit) + result, was_single = materialize_circuits(bell_circuit) assert len(result) == 1 assert was_single is True def test_list_of_circuits(self, bell_circuit, ghz_circuit): """List of circuits passes through.""" - result, was_single = _materialize_circuits([bell_circuit, ghz_circuit]) + result, was_single = materialize_circuits([bell_circuit, ghz_circuit]) assert len(result) == 2 assert was_single is False @@ -104,7 +106,7 @@ def circuit_gen(): yield qc gen = circuit_gen() - result, _ = _materialize_circuits(gen) + result, _ = materialize_circuits(gen) assert len(result) == 3 @@ -121,7 +123,7 @@ def test_same_structure_same_hash(self): qc2.h(0) qc2.cx(0, 1) - assert _compute_circuit_hash([qc1]) == _compute_circuit_hash([qc2]) + assert compute_circuit_hash([qc1]) == compute_circuit_hash([qc2]) def test_different_gates_different_hash(self): """Different gates produce different hash.""" @@ -131,7 +133,7 @@ def test_different_gates_different_hash(self): qc2 = QuantumCircuit(2) qc2.x(0) - assert _compute_circuit_hash([qc1]) != _compute_circuit_hash([qc2]) + assert compute_circuit_hash([qc1]) != compute_circuit_hash([qc2]) def test_parameter_values_dont_change_hash(self): """Parameter values don't affect structure hash.""" @@ -142,7 +144,7 @@ def test_parameter_values_dont_change_hash(self): bound1 = qc.assign_parameters({theta: 0.5}) bound2 = qc.assign_parameters({theta: 1.5}) - assert _compute_circuit_hash([bound1]) == _compute_circuit_hash([bound2]) + assert compute_circuit_hash([bound1]) == compute_circuit_hash([bound2]) class TestTrackedBackendExecution: @@ -257,7 +259,7 @@ def test_envelope_created(self, bell_circuit, aer_simulator, store, registry): _, envelope = _load_envelope(run.run_id, store, registry) assert envelope is not None - assert envelope["schema"] == "devqubit.envelope/0.1" + assert envelope["schema"] == "devqubit.envelope/1.0" assert "device" in envelope assert "program" in envelope assert "execution" in envelope @@ -418,7 +420,7 @@ def test_envelope_created_on_snapshot_error(self, aer_simulator, store, registry qc.measure_all() with patch( - "devqubit_qiskit.adapter.create_device_snapshot", + "devqubit_qiskit.envelope.create_device_snapshot", side_effect=RuntimeError("Snapshot failed"), ): with track(project="test", store=store, registry=registry) as run: diff --git a/packages/devqubit-qiskit/tests/test_qiskit_snapshot.py b/packages/devqubit-qiskit/tests/test_qiskit_snapshot.py index 0827642..002a008 100644 --- a/packages/devqubit-qiskit/tests/test_qiskit_snapshot.py +++ b/packages/devqubit-qiskit/tests/test_qiskit_snapshot.py @@ -196,7 +196,7 @@ class MinimalBackend: snapshot = create_device_snapshot(MinimalBackend()) assert snapshot.backend_name == "MinimalBackend" - assert snapshot.provider == "qiskit" + assert snapshot.provider == "local" # Physical provider, not SDK assert snapshot.num_qubits is None assert snapshot.calibration is None