diff --git a/README.md b/README.md index bc46c6ee..70bb2729 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,49 @@ It wraps the functions provided in the module `quantumaudio.tools.stream` that h ### Running on Native Backends -A Scheme's ```decode()``` method uses local [_AerSimulator_](https://github.com/Qiskit/qiskit-aer) as the default backend. Internally, the function calls `utils.execute()` method that performs ```backend.run()```. Any such backend object compatible with Qiskit can be passed to the ```backend=``` parameter of the `decode()` function. To configure this further or to use primitives, please refer to custom [execute functions](#custom_functions). +A Scheme's `decode()` method runs the circuit through one of the framework-agnostic backends registered in `quantumaudio.backends`. Each is auto-registered if its underlying library is installed, all default to a local simulator, and the choice is selected by name on `decode(spec, backend="...")`: + +| `backend=` | Library / extras | Local default | Install | +|---|---|---|---| +| `"qiskit"` (default) | `qiskit`, `qiskit_aer` | `AerSimulator()` | included | +| `"cirq"` | `cirq-core` | `cirq.Simulator()` | `pip install "quantumaudio[cirq]"` | +| `"pennylane"` | `pennylane` | `default.qubit` | `pip install "quantumaudio[pennylane]"` | +| `"cudaq"` | `cudaq` | `qpp-cpu` (CPU statevector) | `pip install "quantumaudio[cudaq]"` | + +```python +spec = scheme.encode(audio) +out = scheme.decode(spec, backend="cudaq", shots=8000) +``` + +`scheme.decode(..., backend="")` always runs **locally**. Reaching the cloud requires constructing a backend instance directly with a vendor device/target name, see below. ### Running on External Quantum Backends -The package allows flexible use of Quantum Hardware from different Providers as the execution of circuits can be done independently. Depending on the results, there are two ways to decode quantum audio: +To submit to vendor hardware (or a vendor-hosted cloud simulator), construct the relevant backend yourself with the device/target name as a constructor argument, then drive it through `build_circuit` / `run` / `decode_counts` (`decode(backend=...)` only accepts registered names, not instances): + +```python +import os +os.environ["IONQ_API_KEY"] = "..." + +# PennyLane plugin route -- IonQ cloud simulator (free): +from quantumaudio.backends.providers.pennylane_backend import PennyLaneBackend +be = PennyLaneBackend(device="ionq.simulator") # or "ionq.qpu" + +# CUDA-Q route -- IonQ via NVIDIA's runtime: +from quantumaudio.backends.providers.cudaq_backend import CudaQBackend +be = CudaQBackend(target="ionq", qpu="simulator") # or qpu="qpu.aria-1" + +native = be.build_circuit(spec) +result = be.run(native, shots=1024) +out = scheme.decode_counts(result.counts, metadata=spec.metadata) +``` + +A single `PennyLaneBackend` reaches every vendor PennyLane has a plugin for (IonQ, Braket, Rigetti, Quantinuum, IQM, Pasqal, ...); install the corresponding plugin (`pennylane-ionq`, `amazon-braket-pennylane-plugin`, ...) for your target. `CudaQBackend` reaches IonQ, Quantinuum, IQM, Anyon, ORCA, Pasqal, Rigetti, QuEra, Braket, etc. via NVIDIA's runtime. + +> [!Caution] +> For vendor targets that default to **real (paid) hardware** when no machine is specified -- IonQ, Anyon, Braket, Infleqtion, OQC, Quantinuum, Scaleway -- `CudaQBackend` refuses to instantiate without an explicit selector kwarg. So `CudaQBackend(target="ionq")` raises; you must pass `qpu="simulator"` (free cloud sim) or `qpu="qpu.aria-1"` (specific QPU). Validate on the cloud simulator first; the audio schemes generate one circuit per `stream()` chunk and per-shot fees add up fast. + +You can also skip the high-level backend entirely and submit through the vendor's own SDK, then feed the result back into the scheme: - **Results Object:** If the result obtained follow the format of [qiskit.result.Result](https://docs.quantum.ibm.com/api/qiskit/qiskit.result.Result) or [qiskit.primitives.PrimitiveResult](https://docs.quantum.ibm.com/api/qiskit/qiskit.primitives.PrimitiveResult), - The audio can be decoded with ```scheme.decode_result(result_object)``` method. @@ -220,19 +258,16 @@ The _QPAM_ scheme's encoding only preserves **num_samples** (_int_) and the norm > The essential keys required for decoding with any scheme can be checked from the scheme's `.keys` attribute. ### Using Custom Functions -The `decode` and `stream` operations can be configured with the following custom functions. They require few mandatory arguments followed by custom preceding keyword arguments (denoted as `**kwargs`). +The `stream` operation can be configured with a custom process function: - **Process Function**: The default process function of `stream()` simply encodes and decodes a chunk of data with default parameters. It can be overriden by passing a custom function to the `process_function=` parameter. The mandatory arguments for the custom process function are `data=` and `scheme=`. ```python processed_data = process_function(data, scheme, **kwargs) ``` -- **Execute Function** : -The default execute function for `decode()` can be overriden by passing a custom function to the `execute_function=` parameter. The mandatory argument for the custom execute function is `circuit=`. (QPAM also expects `shots=` since it's a metadata) -```python -result = execute_function(circuit, **kwargs) -``` -**Example**: An optional execute function is included in the package which uses [Sampler Primitive](https://docs.quantum.ibm.com/api/qiskit-ibm-runtime/qiskit_ibm_runtime.SamplerV2): `quantumaudio.utils.execute_with_sampler` that can be passed to the `decode()` method. It requires the dependency `pip install qiskit-ibm-runtime`.
- + +To use an alternative circuit-execution path (a different framework, a different vendor, or a custom orchestration layer), register a `Backend` subclass via `quantumaudio.backends.registry` and select it by name from `decode(backend=...)`, or follow the explicit `build_circuit` / `run` / `decode_counts` pattern shown in the [External Quantum Backends](#running-on-external-quantum-backends) section above. + + ## 📘 Additional Resources ### Notebook Examples For examples of circuit preparation, signal reconstruction, and interactive demonstrations, please check the [Demo Notebook](https://github.com/moth-quantum/quantum-audio/blob/main/demos/1_Basics_Walkthrough.ipynb). It combines the core package with additional functions from the `demos/tools` folder to go through Visual and Digital Audio examples. diff --git a/pyproject.toml b/pyproject.toml index 2b9aae41..f3b5246f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,9 @@ Repository = "https://github.com/moth-quantum/quantum-audio" Documentation = "https://quantumaudio.readthedocs.io/" [project.optional-dependencies] +cirq = ["cirq-core"] +pennylane = ["pennylane"] +cudaq = ["cudaq"] demos = [ "soundfile==0.12.1", "librosa==0.10.2.post1", diff --git a/quantumaudio/__init__.py b/quantumaudio/__init__.py index d4c8055f..1b1de485 100644 --- a/quantumaudio/__init__.py +++ b/quantumaudio/__init__.py @@ -117,6 +117,7 @@ def __dir__(): "schemes", "utils", "tools", + "backends", "load_scheme", "encode", "decode", diff --git a/quantumaudio/backends/__init__.py b/quantumaudio/backends/__init__.py new file mode 100644 index 00000000..f9f76b52 --- /dev/null +++ b/quantumaudio/backends/__init__.py @@ -0,0 +1,58 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Framework-agnostic backend layer for quantumaudio. + +This subpackage provides a backend abstraction that allows quantum +circuits built by quantumaudio schemes to be executed on different +quantum frameworks (Qiskit, Cirq, and others). +""" + +from quantumaudio.backends._optional import is_available, require +from quantumaudio.backends.core import ( + CircuitSpec, + GateOp, + GateType, + UnifiedResult, + Backend, + registry, +) + +# Trigger auto-registration of installed backends. +import quantumaudio.backends.providers # noqa: F401 + + +def available_backends() -> list[str]: + """Return names of all backends whose dependencies are installed.""" + return registry.available() + + +def get_backend(name: str = "qiskit") -> Backend: + """Instantiate and return a backend by name.""" + return registry.get(name) + + +__all__ = [ + "Backend", + "CircuitSpec", + "GateOp", + "GateType", + "UnifiedResult", + "available_backends", + "get_backend", + "is_available", + "registry", + "require", +] diff --git a/quantumaudio/backends/_optional.py b/quantumaudio/backends/_optional.py new file mode 100644 index 00000000..477f055d --- /dev/null +++ b/quantumaudio/backends/_optional.py @@ -0,0 +1,56 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Helpers for guarding optional backend dependencies.""" + +from __future__ import annotations + +import importlib +import importlib.util +from types import ModuleType + + +def is_available(package: str) -> bool: + """Check whether *package* can be imported without importing it.""" + return importlib.util.find_spec(package) is not None + + +def require( + package: str, extras_name: str | None = None +) -> ModuleType: + """Import and return *package*, raising a helpful error if missing. + + Only a missing top-level package is converted into the "install + quantumaudio[]" hint. Import errors raised inside an + installed dependency (e.g. due to a broken transitive dep) are + re-raised unchanged so the original traceback is preserved. + + Args: + package: Dotted module name, e.g. ``"cirq"`` or ``"qiskit"``. + extras_name: Optional pip extras hint used in the error message. + """ + top_level = package.split(".", 1)[0] + try: + return importlib.import_module(package) + except ModuleNotFoundError as e: + # Only convert "the requested package itself is missing" into + # the install hint; let nested missing modules propagate. + if e.name in (package, top_level): + hint = extras_name or top_level + raise ImportError( + f"{package!r} is required but not installed. " + f"Install it with: pip install quantumaudio[{hint}]" + ) from None + raise diff --git a/quantumaudio/backends/core/__init__.py b/quantumaudio/backends/core/__init__.py new file mode 100644 index 00000000..33ca420a --- /dev/null +++ b/quantumaudio/backends/core/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +from quantumaudio.backends.core.types import GateType +from quantumaudio.backends.core.circuit import CircuitSpec, GateOp +from quantumaudio.backends.core.result import UnifiedResult +from quantumaudio.backends.core.backend import ( + Backend, + BackendRegistry, + registry, +) + +__all__ = [ + "Backend", + "BackendRegistry", + "CircuitSpec", + "GateOp", + "GateType", + "UnifiedResult", + "registry", +] diff --git a/quantumaudio/backends/core/backend.py b/quantumaudio/backends/core/backend.py new file mode 100644 index 00000000..0f3d9e03 --- /dev/null +++ b/quantumaudio/backends/core/backend.py @@ -0,0 +1,86 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Backend abstract base class and registry.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +import numpy as np + +from quantumaudio.backends.core.circuit import CircuitSpec +from quantumaudio.backends.core.result import UnifiedResult + + +class Backend(ABC): + """Interface that every quantum backend must implement.""" + + name: str + + @abstractmethod + def build_circuit(self, spec: CircuitSpec) -> Any: + """Translate a CircuitSpec into the backend's native circuit.""" + + @abstractmethod + def run( + self, native_circuit: Any, shots: int = 1024 + ) -> UnifiedResult: + """Execute a native circuit and return a UnifiedResult.""" + + @abstractmethod + def statevector(self, native_circuit: Any) -> np.ndarray: + """Return the exact statevector (simulator only).""" + + def run_spec( + self, spec: CircuitSpec, shots: int = 1024 + ) -> UnifiedResult: + """Convenience: build and run in one call.""" + native = self.build_circuit(spec) + return self.run(native, shots) + + +class BackendRegistry: + """Singleton that tracks available backends.""" + + _instance: BackendRegistry | None = None + _backends: dict[str, type[Backend]] + + def __new__(cls) -> BackendRegistry: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._backends = {} + return cls._instance + + def register(self, name: str, cls_: type[Backend]) -> None: + self._backends[name] = cls_ + + def get(self, name: str) -> Backend: + """Instantiate and return a backend by name.""" + if name not in self._backends: + available = ", ".join(self._backends) or "(none)" + raise KeyError( + f"Backend {name!r} not available. " + f"Installed: {available}" + ) + return self._backends[name]() + + def available(self) -> list[str]: + """Return names of all registered backends.""" + return list(self._backends) + + +registry = BackendRegistry() diff --git a/quantumaudio/backends/core/circuit.py b/quantumaudio/backends/core/circuit.py new file mode 100644 index 00000000..edc0e8ad --- /dev/null +++ b/quantumaudio/backends/core/circuit.py @@ -0,0 +1,308 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Framework-agnostic quantum circuit description.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Sequence + +import numpy as np + +from quantumaudio.backends.core.types import GateType + + +@dataclass(frozen=True) +class GateOp: + """A single gate operation. + + ``params`` carries gate angles (e.g. ``RY(theta)``); ``clbits`` + carries the classical-bit targets for measurements. Keeping the + two separate avoids overloading ``params`` for non-angle data. + """ + + gate: GateType + qubits: tuple[int, ...] + params: tuple[float, ...] = () + clbits: tuple[int, ...] = () + + +@dataclass +class CircuitSpec: + """Framework-agnostic quantum circuit specification. + + This is a pure data structure. Backends translate it into their + native circuit representation for execution. The builder API + mirrors the gate-method style used by Qiskit and MicroMoth so + that circuits read naturally. + """ + + num_qubits: int + num_clbits: int = 0 + ops: list[GateOp] = field(default_factory=list) + metadata: dict = field(default_factory=dict) + name: str = "" + + # -- single-qubit gates ----------------------------------------------- + + def h(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.H, (qubit,))) + return self + + def x(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.X, (qubit,))) + return self + + def y(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.Y, (qubit,))) + return self + + def z(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.Z, (qubit,))) + return self + + def s(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.S, (qubit,))) + return self + + def t(self, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.T, (qubit,))) + return self + + def rx(self, theta: float, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.RX, (qubit,), (theta,))) + return self + + def ry(self, theta: float, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.RY, (qubit,), (theta,))) + return self + + def rz(self, theta: float, qubit: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.RZ, (qubit,), (theta,))) + return self + + # -- two-qubit gates --------------------------------------------------- + + def cx(self, control: int, target: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.CX, (control, target))) + return self + + def cz(self, control: int, target: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.CZ, (control, target))) + return self + + def crx( + self, theta: float, control: int, target: int + ) -> CircuitSpec: + self.ops.append( + GateOp(GateType.CRX, (control, target), (theta,)) + ) + return self + + def cry( + self, theta: float, control: int, target: int + ) -> CircuitSpec: + self.ops.append( + GateOp(GateType.CRY, (control, target), (theta,)) + ) + return self + + def crz( + self, theta: float, control: int, target: int + ) -> CircuitSpec: + self.ops.append( + GateOp(GateType.CRZ, (control, target), (theta,)) + ) + return self + + def swap(self, qubit1: int, qubit2: int) -> CircuitSpec: + self.ops.append(GateOp(GateType.SWAP, (qubit1, qubit2))) + return self + + # -- multi-controlled gates -------------------------------------------- + + def mcx( + self, controls: Sequence[int], target: int + ) -> CircuitSpec: + """Multi-controlled X gate.""" + self.ops.append( + GateOp(GateType.MCX, (*tuple(controls), target)) + ) + return self + + def mcry( + self, theta: float, controls: Sequence[int], target: int + ) -> CircuitSpec: + """Multi-controlled RY gate.""" + self.ops.append( + GateOp( + GateType.MCRY, + (*tuple(controls), target), + (theta,), + ) + ) + return self + + # -- state preparation ------------------------------------------------- + + def initialize(self, state: Sequence[float]) -> CircuitSpec: + """Prepare the register in the given non-negative real state. + + Decomposes the state preparation into RY and CX gates using + the Mottonen method so that all backends only need to handle + elementary gates. The decomposition uses magnitudes via + ``arctan2`` and therefore only encodes non-negative real + amplitudes (e.g. QPAM probability amplitudes); signed or + complex states are not supported. + """ + state = np.asarray(state, dtype=float) + n = self.num_qubits + expected = 2**n + if len(state) != expected: + raise ValueError( + f"State vector length {len(state)} does not match " + f"the expected number of amplitudes " + f"2**{n} = {expected}" + ) + norm = np.linalg.norm(state) + if norm < 1e-15: + return self + state = state / norm + _apply_mottonen(self, state, n) + return self + + # -- measurement ------------------------------------------------------- + + def measure(self, qubit: int, clbit: int) -> CircuitSpec: + self.ops.append( + GateOp(GateType.MEASURE, (qubit,), clbits=(clbit,)) + ) + self.num_clbits = max(self.num_clbits, clbit + 1) + return self + + def measure_all(self) -> CircuitSpec: + """Measure every qubit into a classical bit of the same index.""" + for q in range(self.num_qubits): + self.measure(q, q) + return self + + # -- visual separator -------------------------------------------------- + + def barrier(self) -> CircuitSpec: + self.ops.append(GateOp(GateType.BARRIER, ())) + return self + + +# ====================================================================== +# Mottonen state-preparation decomposition (non-negative real states) +# ====================================================================== + + +def _apply_mottonen(spec: CircuitSpec, state: np.ndarray, n: int): + """Decompose a non-negative real state vector into RY and CX gates. + + Uses the Mottonen method: compute a tree of rotation angles, + then apply uniformly controlled RY rotations from the most + significant qubit down to the least significant. + + The qubit convention follows Qiskit's little-endian ordering: + qubit 0 is the least significant bit of the state index. + + Angles are derived from amplitude magnitudes, so signed inputs + are encoded as their absolute values. + """ + angles_tree = _compute_angles(state, n) + for level in range(n): + target = n - 1 - level + controls = list(range(target + 1, n)) + # Reverse controls so the UCR's recursive even/odd split + # matches the angle indexing order. + controls.reverse() + thetas = angles_tree[level] + _apply_ucry(spec, thetas, controls, target) + + +def _compute_angles(state: np.ndarray, n: int) -> list[list[float]]: + """Compute the rotation angle tree for Mottonen decomposition. + + Level 0 targets the MSB (qubit n-1) with 1 angle; level k + targets qubit n-1-k with 2^k angles. Each angle splits a + pair of amplitude sub-blocks via + theta = 2 * arctan2(norm_lower, norm_upper). + """ + all_angles = [] + for level in range(n): + k = n - 1 - level + stride = 2 ** (k + 1) + half = stride // 2 + num_pairs = len(state) // stride + angles = [] + for j in range(num_pairs): + start = j * stride + upper = state[start : start + half] + lower = state[start + half : start + stride] + norm_upper = np.linalg.norm(upper) + norm_lower = np.linalg.norm(lower) + if norm_upper + norm_lower < 1e-15: + angles.append(0.0) + else: + angles.append( + 2.0 * np.arctan2(norm_lower, norm_upper) + ) + all_angles.append(angles) + return all_angles + + +def _apply_ucry( + spec: CircuitSpec, + thetas: list[float], + controls: list[int], + target: int, +): + """Apply a uniformly controlled RY rotation. + + Recursively decomposes into CX and RY gates: + UCR_Y(theta_0, ..., theta_{2^k-1}) = + UCR_Y(alpha_even) @ CX(last_ctrl, target) + @ UCR_Y(alpha_odd) @ CX(last_ctrl, target) + + where alpha_even[i] = (theta[2i] + theta[2i+1]) / 2 + and alpha_odd[i] = (theta[2i] - theta[2i+1]) / 2. + + Base case (no controls): a single RY(theta, target). + """ + if not controls: + # Base case: single rotation. + if abs(thetas[0]) > 1e-15: + spec.ry(thetas[0], target) + return + + n = len(thetas) + even = [ + (thetas[2 * i] + thetas[2 * i + 1]) / 2 + for i in range(n // 2) + ] + odd = [ + (thetas[2 * i] - thetas[2 * i + 1]) / 2 + for i in range(n // 2) + ] + last_ctrl = controls[-1] + remaining = controls[:-1] + + _apply_ucry(spec, even, remaining, target) + spec.cx(last_ctrl, target) + _apply_ucry(spec, odd, remaining, target) + spec.cx(last_ctrl, target) diff --git a/quantumaudio/backends/core/result.py b/quantumaudio/backends/core/result.py new file mode 100644 index 00000000..4c55bf43 --- /dev/null +++ b/quantumaudio/backends/core/result.py @@ -0,0 +1,123 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Unified result type that normalises output across backends.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + +from quantumaudio.backends._optional import require + + +@dataclass +class UnifiedResult: + """Backend-agnostic execution result. + + Every backend produces one of these so that downstream code never + has to care which framework ran the circuit. + """ + + counts: dict[str, int] + shots: int + backend_name: str + metadata: dict = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.shots <= 0: + raise ValueError( + f"shots must be positive, got {self.shots}. " + "Statevector-only execution should not produce a " + "UnifiedResult." + ) + + def probabilities(self) -> dict[str, float]: + """Return the probability distribution over bitstrings.""" + return {k: v / self.shots for k, v in self.counts.items()} + + def probabilities_array(self) -> np.ndarray: + """Return probabilities as a dense array indexed by integer + bitstring value.""" + if not self.counts: + return np.array([]) + n_bits = len(next(iter(self.counts))) + probs = np.zeros(2**n_bits) + for bitstring, count in self.counts.items(): + probs[int(bitstring, 2)] = count / self.shots + return probs + + def marginal(self, qubits: list[int]) -> UnifiedResult: + """Marginalise the result over the given qubit indices.""" + if not self.counts: + return UnifiedResult( + {}, self.shots, self.backend_name, self.metadata + ) + n_bits = len(next(iter(self.counts))) + new_counts: dict[str, int] = {} + for bitstring, count in self.counts.items(): + # Qubits are indexed from the right in the bitstring. + new_key = "".join( + bitstring[n_bits - 1 - q] + for q in sorted(qubits, reverse=True) + ) + new_counts[new_key] = ( + new_counts.get(new_key, 0) + count + ) + return UnifiedResult( + new_counts, self.shots, self.backend_name, self.metadata + ) + + def to_qiskit_result(self): + """Construct a ``qiskit.result.Result`` for compatibility with + code that does isinstance checks on Qiskit result types.""" + require("qiskit", extras_name="qiskit") + from qiskit.result import Result # noqa: PLC0415 + from qiskit.result.models import ( # noqa: PLC0415 + ExperimentResult, + ExperimentResultData, + ) + + if self.counts: + n_bits = len(next(iter(self.counts))) + hex_width = (n_bits + 3) // 4 + hex_counts = { + "0x" + + format(int(k, 2), f"0{hex_width}x"): v + for k, v in self.counts.items() + } + else: + hex_counts = {} + n_bits = 0 + + exp_data = ExperimentResultData(counts=hex_counts) + exp_result = ExperimentResult( + shots=self.shots, + success=True, + data=exp_data, + header={ + "metadata": self.metadata, + "memory_slots": n_bits, + }, + ) + return Result( + backend_name=self.backend_name, + backend_version="0.0.0", + qobj_id="quantumaudio-backends", + job_id="quantumaudio-backends", + success=True, + results=[exp_result], + ) diff --git a/quantumaudio/backends/core/types.py b/quantumaudio/backends/core/types.py new file mode 100644 index 00000000..90dbacd0 --- /dev/null +++ b/quantumaudio/backends/core/types.py @@ -0,0 +1,55 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Gate types for framework-agnostic circuit descriptions.""" + +from enum import Enum, auto + + +class GateType(Enum): + """Universal gate set for quantum audio circuits. + + Covers every operation used by the quantumaudio scheme + implementations across all supported backends. + """ + + # Single-qubit gates. + H = auto() + X = auto() + Y = auto() + Z = auto() + S = auto() + T = auto() + RX = auto() + RY = auto() + RZ = auto() + + # Two-qubit gates. + CX = auto() + CZ = auto() + CRX = auto() + CRY = auto() + CRZ = auto() + SWAP = auto() + + # Multi-controlled gates. + MCX = auto() + MCRY = auto() + + # Measurement. + MEASURE = auto() + + # Visual separator (no-op for execution). + BARRIER = auto() diff --git a/quantumaudio/backends/providers/__init__.py b/quantumaudio/backends/providers/__init__.py new file mode 100644 index 00000000..664acbd6 --- /dev/null +++ b/quantumaudio/backends/providers/__init__.py @@ -0,0 +1,47 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Auto-registration of available backend providers.""" + +from quantumaudio.backends._optional import is_available +from quantumaudio.backends.core.backend import registry + +if is_available("qiskit"): + from quantumaudio.backends.providers.qiskit_backend import ( + QiskitBackend, + ) + + registry.register("qiskit", QiskitBackend) + +if is_available("cirq"): + from quantumaudio.backends.providers.cirq_backend import ( + CirqBackend, + ) + + registry.register("cirq", CirqBackend) + +if is_available("pennylane"): + from quantumaudio.backends.providers.pennylane_backend import ( + PennyLaneBackend, + ) + + registry.register("pennylane", PennyLaneBackend) + +if is_available("cudaq"): + from quantumaudio.backends.providers.cudaq_backend import ( + CudaQBackend, + ) + + registry.register("cudaq", CudaQBackend) diff --git a/quantumaudio/backends/providers/cirq_backend.py b/quantumaudio/backends/providers/cirq_backend.py new file mode 100644 index 00000000..e5bb625b --- /dev/null +++ b/quantumaudio/backends/providers/cirq_backend.py @@ -0,0 +1,174 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Cirq backend for quantumaudio.""" + +from __future__ import annotations + +import numpy as np + +from quantumaudio.backends._optional import require +from quantumaudio.backends.core.backend import Backend +from quantumaudio.backends.core.circuit import CircuitSpec, GateOp +from quantumaudio.backends.core.result import UnifiedResult +from quantumaudio.backends.core.types import GateType + +cirq = require("cirq", extras_name="cirq") + + +def _qubits(n: int): + """Create a list of Cirq LineQubits.""" + return cirq.LineQubit.range(n) + + +_SINGLE_GATE = { + GateType.H: lambda: cirq.H, + GateType.X: lambda: cirq.X, + GateType.Y: lambda: cirq.Y, + GateType.Z: lambda: cirq.Z, + GateType.S: lambda: cirq.S, + GateType.T: lambda: cirq.T, +} + +_PARAM_GATE = { + GateType.RX: cirq.rx, + GateType.RY: cirq.ry, + GateType.RZ: cirq.rz, +} + +_TWO_GATE = { + GateType.CX: lambda: cirq.CNOT, + GateType.CZ: lambda: cirq.CZ, + GateType.SWAP: lambda: cirq.SWAP, +} + +_CTRL_PARAM = { + GateType.CRX: cirq.rx, + GateType.CRY: cirq.ry, + GateType.CRZ: cirq.rz, +} + + +def _apply_op(qubits, op: GateOp) -> list: # noqa: PLR0911 + """Return a list of Cirq operations for a single GateOp.""" + g = op.gate + idx = op.qubits + + if g in _SINGLE_GATE: + return [_SINGLE_GATE[g]()(qubits[idx[0]])] + if g in _PARAM_GATE: + return [_PARAM_GATE[g](op.params[0])(qubits[idx[0]])] + if g in _TWO_GATE: + return [ + _TWO_GATE[g]()(qubits[idx[0]], qubits[idx[1]]) + ] + if g in _CTRL_PARAM: + return [ + _CTRL_PARAM[g](op.params[0])( + qubits[idx[1]] + ).controlled_by(qubits[idx[0]]) + ] + if g == GateType.MCX: + ctrls = [qubits[i] for i in idx[:-1]] + return [cirq.X(qubits[idx[-1]]).controlled_by(*ctrls)] + if g == GateType.MCRY: + ctrls = [qubits[i] for i in idx[:-1]] + return [ + cirq.ry(op.params[0])( + qubits[idx[-1]] + ).controlled_by(*ctrls) + ] + if g == GateType.MEASURE: + return [ + cirq.measure(qubits[idx[0]], key=str(idx[0])) + ] + # BARRIER and anything else: no-op. + return [] + + +class CirqBackend(Backend): + """Google Cirq simulator backend.""" + + name = "cirq" + + def build_circuit( + self, spec: CircuitSpec + ) -> dict: + """Build a Cirq circuit from a CircuitSpec. + + Returns a dict with the circuit, qubits, and metadata + for use by :meth:`run` and :meth:`statevector`. + """ + n = spec.num_qubits + qubits = _qubits(n) + moments_ops = [] + for op in spec.ops: + ops = _apply_op(qubits, op) + moments_ops.extend(ops) + circuit = cirq.Circuit(moments_ops) + return { + "circuit": circuit, + "qubits": qubits, + "num_qubits": n, + "metadata": { + k: v + for k, v in spec.metadata.items() + if k != "registers" + }, + } + + def run( + self, native_circuit: dict, shots: int = 1024 + ) -> UnifiedResult: + """Execute the circuit and return UnifiedResult.""" + circuit = native_circuit["circuit"] + n = native_circuit["num_qubits"] + metadata = native_circuit["metadata"] + + sim = cirq.Simulator() + result = sim.run(circuit, repetitions=shots) + + # Build bitstrings from per-qubit measurement keys. + # Reverse to match Qiskit's little-endian convention: + # qubit 0 is the rightmost bit. + counts: dict[str, int] = {} + for i in range(shots): + bits = [] + for q in range(n - 1, -1, -1): + key = str(q) + bits.append( + str(result.measurements[key][i, 0]) + ) + bitstring = "".join(bits) + counts[bitstring] = counts.get(bitstring, 0) + 1 + + return UnifiedResult(counts, shots, self.name, metadata) + + def statevector(self, native_circuit: dict) -> np.ndarray: + """Return the exact statevector.""" + circuit = native_circuit["circuit"] + + # Strip measurement gates for statevector computation. + clean_ops = [ + op + for moment in circuit.moments + for op in moment.operations + if not cirq.is_measurement(op) + ] + clean = cirq.Circuit(clean_ops) + + sim = cirq.Simulator() + sv_result = sim.simulate(clean) + return sv_result.final_state_vector diff --git a/quantumaudio/backends/providers/cudaq_backend.py b/quantumaudio/backends/providers/cudaq_backend.py new file mode 100644 index 00000000..9710cb6c --- /dev/null +++ b/quantumaudio/backends/providers/cudaq_backend.py @@ -0,0 +1,248 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""NVIDIA CUDA-Q backend for quantumaudio.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np + +from quantumaudio.backends._optional import require +from quantumaudio.backends.core.backend import Backend +from quantumaudio.backends.core.circuit import CircuitSpec, GateOp +from quantumaudio.backends.core.result import UnifiedResult +from quantumaudio.backends.core.types import GateType + +cudaq = require("cudaq", extras_name="cudaq") +# `cudaq.mlir.ir` is only importable once `cudaq` itself is loaded. +from cudaq.mlir import ir as _ir # noqa: E402 + + +_NVQPP_PREFIX = "__nvqpp__mlirgen__" + + +def _rename_kernel(kernel, new_name: str) -> None: + """Rename a CUDA-Q kernel built by ``make_kernel``. + + The kernel name surfaces as the job name on vendor consoles + (e.g. IonQ), but it also keys the MLIR function lookup used at + sampling time. Setting ``kernel.name`` alone leaves the underlying + MLIR symbol unchanged and breaks compilation, so we update both + the Python attributes and the MLIR ``sym_name`` together. + """ + new_func_name = _NVQPP_PREFIX + new_name + new_entry_point = new_name + ".PyKernelFakeEntryPoint" + kernel.funcOp.attributes["sym_name"] = _ir.StringAttr.get( + new_func_name, context=kernel.ctx + ) + kernel.module.operation.attributes["quake.mangled_name_map"] = ( + _ir.DictAttr.get( + { + new_func_name: _ir.StringAttr.get( + new_entry_point, context=kernel.ctx + ) + }, + context=kernel.ctx, + ) + ) + kernel.uniqName = new_name + kernel.name = new_name + kernel.funcName = new_func_name + kernel.funcNameEntryPoint = new_entry_point + + +# Map GateType to the kernel-builder method name that takes the same +# argument shape (single target, single param + target, two qubits, etc.). +_SINGLE_GATE = { + GateType.H: "h", + GateType.X: "x", + GateType.Y: "y", + GateType.Z: "z", + GateType.S: "s", + GateType.T: "t", +} + +_PARAM_GATE = { + GateType.RX: "rx", + GateType.RY: "ry", + GateType.RZ: "rz", +} + +_TWO_GATE = { + GateType.CX: "cx", + GateType.CZ: "cz", + GateType.SWAP: "swap", +} + +_CTRL_PARAM = { + GateType.CRX: "crx", + GateType.CRY: "cry", + GateType.CRZ: "crz", +} + + +def _apply_op(kernel, qubits, op: GateOp) -> None: # noqa: PLR0911 + """Emit one CUDA-Q kernel operation for a single GateOp.""" + g = op.gate + idx = op.qubits + + if g in _SINGLE_GATE: + getattr(kernel, _SINGLE_GATE[g])(qubits[idx[0]]) + return + if g in _PARAM_GATE: + getattr(kernel, _PARAM_GATE[g])( + op.params[0], qubits[idx[0]] + ) + return + if g in _TWO_GATE: + getattr(kernel, _TWO_GATE[g])( + qubits[idx[0]], qubits[idx[1]] + ) + return + if g in _CTRL_PARAM: + getattr(kernel, _CTRL_PARAM[g])( + op.params[0], qubits[idx[0]], qubits[idx[1]] + ) + return + if g == GateType.MCX: + # cx accepts a list of control qubits for multi-control. + controls = [qubits[i] for i in idx[:-1]] + kernel.cx(controls, qubits[idx[-1]]) + return + if g == GateType.MCRY: + controls = [qubits[i] for i in idx[:-1]] + kernel.cry(op.params[0], controls, qubits[idx[-1]]) + return + if g == GateType.MEASURE: + kernel.mz(qubits[idx[0]]) + return + # BARRIER and anything else: no-op for execution. + + +# Remote vendor targets where omitting a machine-selector kwarg routes +# to *real* QPU hardware (paid). Mapping is target name -> name of the +# kwarg that selects simulator vs specific QPU. Omitting the kwarg is +# blocked at construction time so a bare `CudaQBackend(target="ionq")` +# cannot silently reach for billed hardware. +_REMOTE_TARGETS_REQUIRE_SELECTOR = { + "ionq": "qpu", + "anyon": "machine", + "braket": "machine", + "infleqtion": "machine", + "oqc": "machine", + "quantinuum": "machine", + "scaleway": "machine", +} + + +class CudaQBackend(Backend): + """NVIDIA CUDA-Q backend. + + Defaults to the ``qpp-cpu`` target so that tests run in CPU-only + environments. Pass any other CUDA-Q target name (e.g. ``"nvidia"``, + ``"nvidia-mgpu"``, ``"tensornet"``, ``"ionq"``, ``"quantinuum"``, + ``"iqm"``, ``"braket"``) to target a different simulator or hardware, + plus any target-specific keyword arguments (e.g. + ``CudaQBackend(target="ionq", qpu="simulator")`` to hit IonQ's cloud + simulator instead of the real QPU). Vendor credentials must be + configured per CUDA-Q's documentation for any non-local target. + + For vendor targets where omitting the machine-selector kwarg defaults + to running on real hardware (``ionq``, ``anyon``, ``braket``, + ``infleqtion``, ``oqc``, ``quantinuum``, ``scaleway``), the + constructor refuses to instantiate without an explicit selector -- + pass e.g. ``qpu="simulator"`` for IonQ's cloud simulator (free) or + ``qpu="qpu.aria-1"`` for a specific QPU. + + Note: ``cudaq.set_target()`` mutates process-global state; this + backend re-asserts its target on every ``run()`` and ``statevector()`` + call, so multiple backend instances in the same process do not + interfere with each other but concurrent use across threads is not + supported. + """ + + name = "cudaq" + + def __init__(self, target: str = "qpp-cpu", **target_kwargs: Any): + selector = _REMOTE_TARGETS_REQUIRE_SELECTOR.get(target) + if selector is not None and selector not in target_kwargs: + raise ValueError( + f"CudaQBackend(target={target!r}) defaults to running " + f"on real hardware, which can be expensive. Pass " + f"{selector}= explicitly: e.g. " + f"{selector}='simulator' for the vendor's free cloud " + f"simulator, or {selector}='' for a specific " + f"QPU." + ) + self._target = target + self._target_kwargs = target_kwargs + + def build_circuit(self, spec: CircuitSpec) -> dict: + """Build a CUDA-Q kernel from a CircuitSpec. + + Returns a dict carrying the kernel and metadata for use by + :meth:`run` and :meth:`statevector`. + """ + kernel = cudaq.make_kernel() + # Override the auto-generated `PythonKernelBuilderInstance..0x...` + # name so jobs are identifiable in vendor consoles (e.g. IonQ). + # Keep the trailing `..0xHEX` suffix because some CUDA-Q code + # paths (e.g. the statevector path) parse the hex id back out + # of the name. + prefix = ( + f"quantumaudio-{spec.name.lower()}" + if spec.name + else "quantumaudio" + ) + _rename_kernel(kernel, f"{prefix}..{hex(id(kernel))}") + qubits = kernel.qalloc(spec.num_qubits) + for op in spec.ops: + _apply_op(kernel, qubits, op) + return { + "kernel": kernel, + "num_qubits": spec.num_qubits, + "metadata": { + k: v + for k, v in spec.metadata.items() + if k != "registers" + }, + } + + def run( + self, native_circuit: dict, shots: int = 1024 + ) -> UnifiedResult: + """Execute on the configured CUDA-Q target.""" + cudaq.set_target(self._target, **self._target_kwargs) + sample = cudaq.sample( + native_circuit["kernel"], shots_count=shots + ) + # CUDA-Q returns counts in big-endian wire order (qubit 0 is + # the leftmost character). Reverse to match the codebase's + # Qiskit little-endian convention. + counts = {k[::-1]: int(v) for k, v in sample.items()} + return UnifiedResult( + counts, shots, self.name, native_circuit["metadata"] + ) + + def statevector(self, native_circuit: dict) -> np.ndarray: + """Return the exact statevector. + + Uses the configured target if it supports state extraction; the + default ``qpp-cpu`` target does. Hardware targets do not. + """ + cudaq.set_target(self._target, **self._target_kwargs) + return np.asarray(cudaq.get_state(native_circuit["kernel"])) diff --git a/quantumaudio/backends/providers/pennylane_backend.py b/quantumaudio/backends/providers/pennylane_backend.py new file mode 100644 index 00000000..6d77c6d3 --- /dev/null +++ b/quantumaudio/backends/providers/pennylane_backend.py @@ -0,0 +1,220 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""PennyLane backend for quantumaudio.""" + +from __future__ import annotations + +from typing import Any, Callable + +import numpy as np + +from quantumaudio.backends._optional import require +from quantumaudio.backends.core.backend import Backend +from quantumaudio.backends.core.circuit import CircuitSpec, GateOp +from quantumaudio.backends.core.result import UnifiedResult +from quantumaudio.backends.core.types import GateType + +qml = require("pennylane", extras_name="pennylane") + + +_SINGLE_GATE = { + GateType.H: qml.Hadamard, + GateType.X: qml.PauliX, + GateType.Y: qml.PauliY, + GateType.Z: qml.PauliZ, + GateType.S: qml.S, + GateType.T: qml.T, +} + +_PARAM_GATE = { + GateType.RX: qml.RX, + GateType.RY: qml.RY, + GateType.RZ: qml.RZ, +} + +_TWO_GATE = { + GateType.CX: qml.CNOT, + GateType.CZ: qml.CZ, + GateType.SWAP: qml.SWAP, +} + +_CTRL_PARAM = { + GateType.CRX: qml.CRX, + GateType.CRY: qml.CRY, + GateType.CRZ: qml.CRZ, +} + + +def _apply_op(op: GateOp) -> None: + """Apply a single GateOp by emitting the matching PennyLane op.""" + g = op.gate + idx = op.qubits + + if g in _SINGLE_GATE: + _SINGLE_GATE[g](wires=idx[0]) + return + if g in _PARAM_GATE: + _PARAM_GATE[g](op.params[0], wires=idx[0]) + return + if g in _TWO_GATE: + _TWO_GATE[g](wires=[idx[0], idx[1]]) + return + if g in _CTRL_PARAM: + _CTRL_PARAM[g](op.params[0], wires=[idx[0], idx[1]]) + return + if g == GateType.MCX: + qml.MultiControlledX(wires=list(idx)) + return + if g == GateType.MCRY: + qml.ctrl(qml.RY, control=list(idx[:-1]))( + op.params[0], wires=idx[-1] + ) + return + # MEASURE is collected separately by build_circuit. + # BARRIER and anything else: no-op for execution. + + +def _kernel_factory( + spec: CircuitSpec, +) -> tuple[Callable[[], None], list[int]]: + """Build a closure that applies all non-measurement ops, plus the + list of wires marked by MEASURE ops in the order encountered.""" + measured_wires: list[int] = [] + gate_ops: list[GateOp] = [] + for op in spec.ops: + if op.gate == GateType.MEASURE: + measured_wires.append(op.qubits[0]) + elif op.gate == GateType.BARRIER: + continue + else: + gate_ops.append(op) + + def kernel_fn() -> None: + for op in gate_ops: + _apply_op(op) + + return kernel_fn, measured_wires + + +def _maybe_name_remote_job(dev, name: str) -> None: + """Inject ``name`` into a PennyLane remote device's per-job dict. + + Plugins like ``pennylane-ionq`` populate ``dev.job`` (a plain dict + that is sent verbatim to the vendor REST endpoint) inside + ``_submit_job``. The plugin doesn't expose a way to set a job name + itself, so we wrap the submit path and add one before the POST. + A no-op for local simulators that don't have ``_submit_job``. + """ + inner = getattr(dev, "_device", dev) + if not hasattr(inner, "_submit_job"): + return + original = inner._submit_job + + def wrapped(): + if isinstance(getattr(inner, "job", None), dict): + inner.job.setdefault("name", name) + return original() + + inner._submit_job = wrapped + + +class PennyLaneBackend(Backend): + """PennyLane backend. + + Defaults to the ``default.qubit`` simulator. Pass any other PennyLane + device name (e.g. ``"lightning.qubit"``, ``"ionq.simulator"``, + ``"ionq.qpu"``) to target a different simulator or hardware. Vendor + plugins must be installed separately. + """ + + name = "pennylane" + + def __init__( + self, device: str = "default.qubit", **device_kwargs: Any + ): + self._device = device + self._device_kwargs = device_kwargs + + def build_circuit(self, spec: CircuitSpec) -> dict: + """Build a closure-based representation of the circuit. + + Returns a dict carrying the gate-emitting kernel function, the + wires to measure, and metadata for use by :meth:`run` and + :meth:`statevector`. + """ + kernel_fn, measured_wires = _kernel_factory(spec) + # Mirror CudaQBackend's naming so remote-job consoles + # (e.g. IonQ) show something identifiable. + job_name = ( + f"quantumaudio-{spec.name.lower()}" + if spec.name + else "quantumaudio" + ) + return { + "kernel_fn": kernel_fn, + "measured_wires": measured_wires, + "num_qubits": spec.num_qubits, + "job_name": job_name, + "metadata": { + k: v + for k, v in spec.metadata.items() + if k != "registers" + }, + } + + def run( + self, native_circuit: dict, shots: int = 1024 + ) -> UnifiedResult: + """Execute on the configured PennyLane device.""" + n = native_circuit["num_qubits"] + kernel_fn = native_circuit["kernel_fn"] + measured = native_circuit["measured_wires"] or list(range(n)) + + dev = qml.device( + self._device, wires=n, **self._device_kwargs + ) + _maybe_name_remote_job(dev, native_circuit["job_name"]) + + @qml.qnode(dev) + def node(): + kernel_fn() + return qml.counts(wires=measured) + + raw = qml.set_shots(node, shots=shots)() + # PennyLane returns counts in big-endian wire order (wire 0 is + # the leftmost character). Reverse to match the codebase's + # Qiskit little-endian convention. + counts = {str(k)[::-1]: int(v) for k, v in raw.items()} + return UnifiedResult( + counts, shots, self.name, native_circuit["metadata"] + ) + + def statevector(self, native_circuit: dict) -> np.ndarray: + """Return the exact statevector via ``default.qubit``. + + Hardware devices do not expose statevectors, so this always + runs on the local simulator regardless of the configured device. + """ + n = native_circuit["num_qubits"] + kernel_fn = native_circuit["kernel_fn"] + dev = qml.device("default.qubit", wires=n) + + @qml.qnode(dev) + def sv_node(): + kernel_fn() + return qml.state() + + return np.asarray(sv_node()) diff --git a/quantumaudio/backends/providers/qiskit_backend.py b/quantumaudio/backends/providers/qiskit_backend.py new file mode 100644 index 00000000..243495ab --- /dev/null +++ b/quantumaudio/backends/providers/qiskit_backend.py @@ -0,0 +1,184 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Qiskit backend for quantumaudio.""" + +from __future__ import annotations + +import numpy as np +import qiskit +import qiskit_aer +from qiskit import ClassicalRegister +from qiskit.circuit.library import RYGate +from qiskit.quantum_info import Statevector +from qiskit.transpiler.preset_passmanagers import ( + generate_preset_pass_manager, +) + +from quantumaudio.backends.core.backend import Backend +from quantumaudio.backends.core.circuit import CircuitSpec, GateOp +from quantumaudio.backends.core.result import UnifiedResult +from quantumaudio.backends.core.types import GateType + + +_SINGLE_QUBIT = { + GateType.H: "h", + GateType.X: "x", + GateType.Y: "y", + GateType.Z: "z", + GateType.S: "s", + GateType.T: "t", +} + +_SINGLE_QUBIT_PARAM = { + GateType.RX: "rx", + GateType.RY: "ry", + GateType.RZ: "rz", +} + +_TWO_QUBIT = { + GateType.CX: "cx", + GateType.CZ: "cz", + GateType.SWAP: "swap", +} + +_TWO_QUBIT_PARAM = { + GateType.CRX: "crx", + GateType.CRY: "cry", + GateType.CRZ: "crz", +} + + +def _apply_op(qc, qubits, op: GateOp): + """Apply a single GateOp to a Qiskit QuantumCircuit.""" + g = op.gate + idx = op.qubits + + if g in _SINGLE_QUBIT: + getattr(qc, _SINGLE_QUBIT[g])(qubits[idx[0]]) + elif g in _SINGLE_QUBIT_PARAM: + getattr(qc, _SINGLE_QUBIT_PARAM[g])( + op.params[0], qubits[idx[0]] + ) + elif g in _TWO_QUBIT: + getattr(qc, _TWO_QUBIT[g])( + qubits[idx[0]], qubits[idx[1]] + ) + elif g in _TWO_QUBIT_PARAM: + getattr(qc, _TWO_QUBIT_PARAM[g])( + op.params[0], qubits[idx[0]], qubits[idx[1]] + ) + elif g == GateType.MCX: + ctrls = [qubits[i] for i in idx[:-1]] + qc.mcx(ctrls, qubits[idx[-1]]) + elif g == GateType.MCRY: + ctrls = [qubits[i] for i in idx[:-1]] + controlled_ry = RYGate(op.params[0]).control( + len(ctrls) + ) + qc.append(controlled_ry, [*ctrls, qubits[idx[-1]]]) + elif g == GateType.MEASURE: + qc.measure(qubits[idx[0]], op.clbits[0]) + elif g == GateType.BARRIER: + qc.barrier() + + +class QiskitBackend(Backend): + """Qiskit / AerSimulator backend.""" + + name = "qiskit" + + def build_circuit(self, spec: CircuitSpec) -> qiskit.QuantumCircuit: + """Translate CircuitSpec to a Qiskit QuantumCircuit. + + If register metadata is present, named QuantumRegisters are + created for better circuit visualisation. + """ + regs_meta = spec.metadata.get("registers", {}) + if regs_meta: + regs = [] + qubit_list = [None] * spec.num_qubits + for reg_name, (start, size) in regs_meta.items(): + if size > 0: + reg = qiskit.QuantumRegister(size, reg_name) + regs.append(reg) + for i in range(size): + qubit_list[start + i] = reg[i] + qc = qiskit.QuantumCircuit( + *regs, name=spec.name + ) + # Map integer qubit indices to Qiskit qubit objects. + qubits = qc.qubits + else: + qc = qiskit.QuantumCircuit( + spec.num_qubits, spec.num_clbits, name=spec.name + ) + qubits = qc.qubits + + # Copy non-register metadata to the Qiskit circuit. + qc.metadata = { + k: v + for k, v in spec.metadata.items() + if k != "registers" + } + + for op in spec.ops: + if op.gate == GateType.MEASURE and not qc.cregs: + qc.add_register( + ClassicalRegister(spec.num_clbits) + ) + _apply_op(qc, qubits, op) + + return qc + + def run( + self, native_circuit, shots: int = 1024 + ) -> UnifiedResult: + """Execute on AerSimulator and return UnifiedResult.""" + backend = qiskit_aer.AerSimulator() + pm = generate_preset_pass_manager( + optimization_level=1, backend=backend + ) + transpiled = pm.run(native_circuit) + job = backend.run(transpiled, shots=shots) + result = job.result() + raw_counts = result.get_counts() + + # Normalise keys to zero-padded binary strings. + n_bits = native_circuit.num_clbits + counts: dict[str, int] = {} + for raw_key, val in raw_counts.items(): + key = raw_key.replace(" ", "") + if key.startswith("0x"): + bits = bin(int(key, 16))[2:].zfill(n_bits) + else: + bits = key.zfill(n_bits) + counts[bits] = counts.get(bits, 0) + val + + metadata = ( + native_circuit.metadata + if native_circuit.metadata + else {} + ) + return UnifiedResult(counts, shots, self.name, metadata) + + def statevector(self, native_circuit) -> np.ndarray: + """Return the exact statevector.""" + # Strip measurements for statevector computation. + qc = native_circuit.remove_final_measurements( + inplace=False + ) + sv = Statevector.from_instruction(qc) + return np.asarray(sv) diff --git a/quantumaudio/schemes/base_scheme.py b/quantumaudio/schemes/base_scheme.py index 58477ebb..31238e45 100644 --- a/quantumaudio/schemes/base_scheme.py +++ b/quantumaudio/schemes/base_scheme.py @@ -15,7 +15,8 @@ from abc import ABC, abstractmethod import numpy as np -import qiskit + +from quantumaudio.backends import CircuitSpec class Scheme(ABC): @@ -29,23 +30,23 @@ class Scheme(ABC): """ @abstractmethod - def encode(self, data: np.ndarray) -> qiskit.QuantumCircuit: + def encode(self, data: np.ndarray) -> CircuitSpec: """Encode the input data using the scheme. Args: data: A `numpy` array containing the data to be encoded. Returns: - A `qiskit.QuantumCircuit` representing the encoded data. + A `CircuitSpec` representing the encoded data. """ pass @abstractmethod - def decode(self, circuit: qiskit.QuantumCircuit) -> np.ndarray: + def decode(self, circuit: CircuitSpec) -> np.ndarray: """Decode the quantum circuit using the scheme. Args: - circuit: A `qiskit.QuantumCircuit` that contains the encoded data. + circuit: A `CircuitSpec` that contains the encoded data. Returns: A `numpy` array containing the decoded data. diff --git a/quantumaudio/schemes/mqsm.py b/quantumaudio/schemes/mqsm.py index ac43ef0c..9bb7f42d 100644 --- a/quantumaudio/schemes/mqsm.py +++ b/quantumaudio/schemes/mqsm.py @@ -13,13 +13,13 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np -import qiskit from bitstring import BitArray from quantumaudio import utils +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -55,13 +55,11 @@ def __init__( n_fold: Term for a fixed number of indexed registers used. labels: Name of the Quantum registers positions: Index position of Quantum registers - (In a Qiskit circuit the registers are arranged - from Top to Bottom) convert: Function that applies a mathematical conversion of input at Encoding. restore: Function that restores the conversion at Decoding. - + keys: Reference to essential metadata keys for decoding. Args: @@ -119,15 +117,15 @@ def calculate( # y-axis num_channels = ( 1 if data.ndim == 1 else data.shape[0] - ) # data-dependent channels + ) if self.num_channels: - num_channels = self.num_channels # override with pre-set channels + num_channels = self.num_channels data_shape = (num_channels, num_samples) num_channel_qubits = utils.get_qubit_count( max(2, num_channels) - ) # apply constraint of minimum 2 channels + ) num_value_qubits = ( utils.get_bit_depth(data) if not self.qubit_depth @@ -135,7 +133,6 @@ def calculate( ) qubit_shape = (num_index_qubits, num_channel_qubits, num_value_qubits) - # print if verbose: utils.print_num_qubits(qubit_shape, labels=self.labels) return data_shape, qubit_shape @@ -175,7 +172,7 @@ def initialize_circuit( num_index_qubits: int, num_channel_qubits: int, num_value_qubits: int, - ) -> qiskit.QuantumCircuit: + ) -> CircuitSpec: """Initializes the circuit with Index, Channel and Value Registers. Args: @@ -184,31 +181,37 @@ def initialize_circuit( num_value_qubits: Number of qubits used to encode the sample values. Returns: - Qiskit Circuit with the registers + CircuitSpec with the appropriate number of qubits. """ - index_register = qiskit.QuantumRegister( - num_index_qubits, self.labels[0] - ) - channel_register = qiskit.QuantumRegister( - num_channel_qubits, self.labels[1] - ) - value_register = qiskit.QuantumRegister( - num_value_qubits, self.labels[2] + total = num_value_qubits + num_channel_qubits + num_index_qubits + spec = CircuitSpec( + num_qubits=total, name=self.__class__.__name__ ) - circuit = qiskit.QuantumCircuit( - value_register, - channel_register, - index_register, - name=self.__class__.__name__, - ) - circuit.h(channel_register) - circuit.h(index_register) - return circuit + v_end = num_value_qubits + c_end = v_end + num_channel_qubits + self._value_range = (0, num_value_qubits) + self._channel_range = (v_end, num_channel_qubits) + self._index_range = (c_end, num_index_qubits) + + spec.metadata["registers"] = { + self.labels[2]: self._value_range, + self.labels[1]: self._channel_range, + self.labels[0]: self._index_range, + } + + # Apply Hadamard to channel and index registers. + ch_start, ch_size = self._channel_range + for q in range(ch_start, ch_start + ch_size): + spec.h(q) + idx_start, idx_size = self._index_range + for q in range(idx_start, idx_start + idx_size): + spec.h(q) + return spec @utils.with_indexing def value_setting( - self, circuit: qiskit.QuantumCircuit, index: int, value: float + self, circuit: CircuitSpec, index: int, value: float ) -> None: """Encodes the prepared, converted values to the initialised circuit. @@ -217,31 +220,35 @@ def value_setting( corresponding to the given index. Args: - circuit: Initialized Qiskit Circuit - index: position to set the value - value: value to be set at the index + circuit: Initialized CircuitSpec. + index: position to set the value. + value: value to be set at the index. Note: This method is used in a loop where each value is iterated and set at its corresponding index. """ - value_register, channel_register, index_register = circuit.qregs - for i, areg_qubit in enumerate(value_register): + v_start, v_size = self._value_range + ch_start, ch_size = self._channel_range + idx_start, idx_size = self._index_range + controls = ( + list(range(ch_start, ch_start + ch_size)) + + list(range(idx_start, idx_start + idx_size)) + ) + for i in range(v_size): a_bit = (value >> i) & 1 if a_bit: - circuit.mcx( - channel_register[:] + index_register[:], areg_qubit - ) + circuit.mcx(controls, v_start + i) - def measure(self, circuit: qiskit.QuantumCircuit) -> None: - """Adds classical measurements to all registers of the Quantum Circuit + def measure(self, circuit: CircuitSpec) -> None: + """Adds classical measurements to all qubits of the circuit if the circuit is not already measured. Args: - circuit: Encoded Qiskit Circuit + circuit: Encoded CircuitSpec. """ - if not circuit.cregs: + if circuit.num_clbits == 0: circuit.measure_all() # ----- Default Encode Function ----- @@ -251,8 +258,8 @@ def encode( data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, - ) -> qiskit.QuantumCircuit: - """Given audio data, prepares a Qiskit Circuit representing it. + ) -> CircuitSpec: + """Given audio data, prepares a CircuitSpec representing it. Args: data: Array representing Digital Audio Samples @@ -263,7 +270,7 @@ def encode( - >2: Displays the encoded circuit. Returns: - A Qiskit Circuit representing the Digital Audio + A CircuitSpec representing the Digital Audio. """ utils.validate_data(data) @@ -286,12 +293,12 @@ def encode( self.value_setting(circuit=circuit, index=i, value=sample) # additional information for decoding - circuit.metadata = { + circuit.metadata.update({ "num_samples": num_samples, "num_channels": num_channels, "qubit_shape": qubit_shape, "scheme": circuit.name, - } + }) # measure if measure: @@ -304,7 +311,7 @@ def encode( def decode_components( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, qubit_shape: Tuple[int, int, int], ) -> np.ndarray: """The first stage of decoding is extracting the required components from @@ -337,20 +344,19 @@ def decode_components( def reconstruct_data( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, qubit_shape: Tuple[int, int, int], ) -> np.ndarray: - """Given counts, Extract components and restore the conversion at + """Given counts, extract components and restore the conversion at encoding stage. Args: counts: a dictionary with the outcome of measurements performed on the quantum circuit. qubit_shape: Tuple to determine the number of (channels, samples) to get. - qubit_depth : number of qubits in amplitude register. Return: - Array of restored values + Array of restored values. """ data = self.decode_components(counts, qubit_shape) data = self.restore(data, bit_depth=qubit_shape[-1]) @@ -358,45 +364,33 @@ def reconstruct_data( def decode_counts( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, metadata: dict, - keep_padding: Tuple[int, int] = (False, False), + keep_padding: Tuple[bool, bool] = (False, False), ) -> np.ndarray: - """Given a Qiskit counts object or Dictionary, Extract components and restore the + """Given a counts dictionary, extract components and restore the conversion did at encoding stage. Args: - counts: a qiskit Counts object or Dictionary obtained from a job result. - metadata: metadata required for decoding. + counts: a counts dictionary obtained from a job result. + metadata: metadata required for decoding. keep_padding: Undo the padding set at Encoding stage if set to False. - Dimension 0 for Channels. - Dimension 1 for Time. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ - index_position, channel_position, amplitude_position = self.positions - - # decoding x-axis qubit_shape = metadata["qubit_shape"] - num_index_qubits = qubit_shape[0] num_channel_qubits = qubit_shape[1] - - num_samples = 2**num_index_qubits num_channels = 2**num_channel_qubits - num_components = (num_channels, num_samples) original_num_samples = metadata["num_samples"] original_num_channels = metadata["num_channels"] - # decoding y-axis - qubit_depth = qubit_shape[2] - - # decoding data data = self.reconstruct_data(counts=counts, qubit_shape=qubit_shape) - # reconstruct data = utils.restore_channels(data, num_channels) if not keep_padding[0]: @@ -409,15 +403,15 @@ def decode_counts( def decode_result( self, - result: qiskit.result.Result, + result, metadata: Optional[dict] = None, - keep_padding: Tuple[int, int] = (False, False), + keep_padding: Tuple[bool, bool] = (False, False), ) -> np.ndarray: - """Given a result object. Extract components and restore the conversion - did in the encoding stage. + """Given a result object, extract components and restore the + conversion did in the encoding stage. Args: - result: a qiskit Result object that contains counts along + result: a result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set to False. @@ -426,7 +420,7 @@ def decode_result( - Dimension 1 for Time. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata @@ -439,30 +433,31 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, - keep_padding: Tuple[int, int] = (False, False), - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + keep_padding: Tuple[bool, bool] = (False, False), + backend=None, **kwargs, ) -> np.ndarray: - """Given a qiskit circuit, decodes and returns the Original Audio Array. + """Given a CircuitSpec, decodes and returns the Original Audio Array. Args: - circuit: A Qiskit Circuit representing the Digital Audio. + circuit: A CircuitSpec representing the Digital Audio. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set False. - execute_function: Function to execute the circuit for decoding. - - - Defaults to :ref:`utils.execute ` which accepts any additional `**kwargs`. + backend: Backend name to execute on (default: "qiskit"). Return: - Array of decoded values + Array of decoded values. """ self.measure(circuit) - result = execute_function(circuit=circuit, **kwargs) - data = self.decode_result( - result=result, metadata=metadata, keep_padding=keep_padding + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + data = self.decode_counts( + counts=result.counts, + metadata=metadata, + keep_padding=keep_padding, ) return data diff --git a/quantumaudio/schemes/msqpam.py b/quantumaudio/schemes/msqpam.py index 821d7be3..3fd7d863 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -13,12 +13,12 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -50,13 +50,11 @@ def __init__(self, num_channels: Optional[int] = None) -> None: n_fold: Term for a fixed number of indexed registers used. labels: Name of the Quantum registers positions: Index position of Quantum registers - (In a Qiskit circuit the registers are arranged - from Top to Bottom) convert: Function that applies a mathematical conversion of input at Encoding. restore: Function that restores the conversion at Decoding. - + keys: Reference to essential metadata keys for decoding. Args: @@ -116,19 +114,18 @@ def calculate( # y-axis num_channels = ( 1 if data.ndim == 1 else data.shape[0] - ) # data-dependent channels + ) if self.num_channels: - num_channels = self.num_channels # override with pre-set channels + num_channels = self.num_channels data_shape = (num_channels, num_samples) num_channel_qubits = utils.get_qubit_count( max(2, num_channels) - ) # apply constraint of minimum 2 channels + ) num_value_qubits = self.qubit_depth qubit_shape = (num_index_qubits, num_channel_qubits, num_value_qubits) - # print if verbose: utils.print_num_qubits(qubit_shape, labels=self.labels) return data_shape, qubit_shape @@ -166,7 +163,7 @@ def initialize_circuit( num_index_qubits: int, num_channel_qubits: int, num_value_qubits: int, - ) -> qiskit.QuantumCircuit: + ) -> CircuitSpec: """Initializes the circuit with Index, Channel and Value Registers. Args: @@ -175,31 +172,37 @@ def initialize_circuit( num_value_qubits: Number of qubits used to encode the sample values. Returns: - Qiskit Circuit with the registers + CircuitSpec with the appropriate number of qubits. """ - index_register = qiskit.QuantumRegister( - num_index_qubits, self.labels[0] - ) - channel_register = qiskit.QuantumRegister( - num_channel_qubits, self.labels[1] - ) - value_register = qiskit.QuantumRegister( - num_value_qubits, self.labels[2] + total = num_value_qubits + num_channel_qubits + num_index_qubits + spec = CircuitSpec( + num_qubits=total, name=self.__class__.__name__ ) - circuit = qiskit.QuantumCircuit( - value_register, - channel_register, - index_register, - name=self.__class__.__name__, - ) - circuit.h(channel_register) - circuit.h(index_register) - return circuit + v_end = num_value_qubits + c_end = v_end + num_channel_qubits + self._value_range = (0, num_value_qubits) + self._channel_range = (v_end, num_channel_qubits) + self._index_range = (c_end, num_index_qubits) + + spec.metadata["registers"] = { + self.labels[2]: self._value_range, + self.labels[1]: self._channel_range, + self.labels[0]: self._index_range, + } + + # Apply Hadamard to channel and index registers. + ch_start, ch_size = self._channel_range + for q in range(ch_start, ch_start + ch_size): + spec.h(q) + idx_start, idx_size = self._index_range + for q in range(idx_start, idx_start + idx_size): + spec.h(q) + return spec @utils.with_indexing def value_setting( - self, circuit: qiskit.QuantumCircuit, index: int, value: float + self, circuit: CircuitSpec, index: int, value: float ) -> None: """Encodes the prepared, converted values to the initialised circuit. This function is used to set a single value at a single index. The @@ -207,39 +210,27 @@ def value_setting( corresponding to the given index. Args: - circuit: Initialized Qiskit Circuit - index: position to set the value - value: value to be set at the index + circuit: Initialized CircuitSpec. + index: position to set the value. + value: value to be set at the index. """ - value_register, channel_register, index_register = circuit.qregs - - # initialise sub-circuit - sub_circuit = qiskit.QuantumCircuit( - name=f"Sample {index} (CH {index%(2**channel_register.size)})" - ) - sub_circuit.add_register(value_register) - - # rotate qubits with values - sub_circuit.ry(2 * value, 0) - - # entangle with index qubits - sub_circuit = sub_circuit.control( - channel_register.size + index_register.size + v_start, _ = self._value_range + ch_start, ch_size = self._channel_range + idx_start, idx_size = self._index_range + controls = ( + list(range(ch_start, ch_start + ch_size)) + + list(range(idx_start, idx_start + idx_size)) ) + circuit.mcry(2 * value, controls, v_start) - # attach sub-circuit - circuit.append( - sub_circuit, list(i for i in range(circuit.num_qubits - 1, -1, -1)) - ) - - def measure(self, circuit: qiskit.QuantumCircuit) -> None: - """Adds classical measurements to all registers of the Quantum Circuit + def measure(self, circuit: CircuitSpec) -> None: + """Adds classical measurements to all qubits of the circuit if the circuit is not already measured. Args: - circuit: Encoded Qiskit Circuit + circuit: Encoded CircuitSpec. """ - if not circuit.cregs: + if circuit.num_clbits == 0: circuit.measure_all() # ----- Default Encode Function ----- @@ -249,8 +240,8 @@ def encode( data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, - ) -> qiskit.QuantumCircuit: - """Given audio data, prepares a Qiskit Circuit representing it. + ) -> CircuitSpec: + """Given audio data, prepares a CircuitSpec representing it. Args: data: Array representing Digital Audio Samples @@ -261,7 +252,7 @@ def encode( - >2: Displays the encoded circuit. Returns: - A Qiskit Circuit representing the Digital Audio + A CircuitSpec representing the Digital Audio. """ utils.validate_data(data) @@ -284,26 +275,26 @@ def encode( self.value_setting(circuit=circuit, index=i, value=sample) # additional information for decoding - circuit.metadata = { + circuit.metadata.update({ "num_samples": num_samples, "num_channels": num_channels, "qubit_shape": qubit_shape, "scheme": circuit.name, - } + }) # measure if measure: self.measure(circuit) if verbose == 2: - utils.draw_circuit(circuit, decompose=1) + utils.draw_circuit(circuit) return circuit # ------------------- Decoding Helpers --------------------------- def decode_components( self, - counts: Union[dict, qiskit.result.Counts], - qubit_shape: Tuple[int, int], + counts: dict, + qubit_shape: Tuple[int, int, int], ) -> np.ndarray: """The first stage of decoding is extracting required components from counts. @@ -317,7 +308,6 @@ def decode_components( 2-D Array of shape (num_channels, num_samples) for further decoding. """ - # initialising components num_index_qubits = qubit_shape[0] num_channel_qubits = qubit_shape[1] @@ -328,7 +318,6 @@ def decode_components( cosine_amps = np.zeros(num_components) sine_amps = np.zeros(num_components) - # getting components from counts for state in counts: index_bits, channel_bits, value_bits = utils.split_string( state, qubit_shape @@ -345,11 +334,11 @@ def decode_components( def reconstruct_data( self, - counts: Union[dict, qiskit.result.Counts], - qubit_shape: Tuple[int, int], + counts: dict, + qubit_shape: Tuple[int, int, int], inverted: bool = False, ) -> np.ndarray: - """Given counts, Extract components and restore the conversion did at + """Given counts, extract components and restore the conversion did at encoding stage. Args: @@ -359,7 +348,7 @@ def reconstruct_data( inverted : retrieves cosine components of the signal. Return: - Array of restored values + Array of restored values. """ cosine_amps, sine_amps = self.decode_components(counts, qubit_shape) data = self.restore(cosine_amps, sine_amps, inverted) @@ -367,16 +356,16 @@ def reconstruct_data( def decode_counts( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, metadata: dict, inverted: bool = False, - keep_padding: Tuple[int, int] = (False, False), + keep_padding: Tuple[bool, bool] = (False, False), ) -> np.ndarray: - """Given a Qiskit counts object or Dictionary, Extract components and restore the + """Given a counts dictionary, extract components and restore the conversion did at encoding stage. Args: - counts: a qiskit Counts object or Dictionary obtained from a job result. + counts: a counts dictionary obtained from a job result. metadata: metadata required for decoding. inverted : retrieves cosine components of the signal. keep_padding: Undo the padding set at Encoding stage if set to False. @@ -385,10 +374,8 @@ def decode_counts( - Dimension 1 for Time. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ - # decoding x-axis - index_position, channel_position, _ = self.positions qubit_shape = metadata["qubit_shape"] num_channel_qubits = qubit_shape[1] @@ -397,7 +384,6 @@ def decode_counts( original_num_samples = metadata["num_samples"] original_num_channels = metadata["num_channels"] - # decoding y-axis data = self.reconstruct_data( counts=counts, qubit_shape=qubit_shape, @@ -417,16 +403,16 @@ def decode_counts( def decode_result( self, - result: qiskit.result.Result, + result, metadata: Optional[dict] = None, inverted: bool = False, - keep_padding: Tuple[int, int] = (False, False), + keep_padding: Tuple[bool, bool] = (False, False), ) -> np.ndarray: - """Given a result object. Extract components and restore the conversion - did in the encoding stage. + """Given a result object, extract components and restore the + conversion did in the encoding stage. Args: - result: a qiskit Result object that contains counts along + result: a result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. inverted : retrieves cosine components of the signal. @@ -436,7 +422,7 @@ def decode_result( - Dimension 1 for Time. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata @@ -452,36 +438,35 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, inverted: bool = False, - keep_padding: Tuple[int, int] = (False, False), - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + keep_padding: Tuple[bool, bool] = (False, False), + backend=None, **kwargs, ) -> np.ndarray: - """Given a qiskit circuit, decodes and returns the Original Audio Array. + """Given a CircuitSpec, decodes and returns the Original Audio Array. Args: - circuit: A Qiskit Circuit representing the Digital Audio. + circuit: A CircuitSpec representing the Digital Audio. metadata: optionally pass metadata as argument. inverted: retrieves cosine components of the signal. keep_padding: Undo the padding set at Encoding stage if set to False. - Dimension 0 for Channels. - Dimension 1 for Time. - execute_function: Function to execute the circuit for decoding. - - - Defaults to :ref:`utils.execute ` which accepts any additional `**kwargs`. + backend: Backend name to execute on (default: "qiskit"). Return: - Array of decoded values + Array of decoded values. """ self.measure(circuit) - result = utils.execute(circuit=circuit, **kwargs) - data = self.decode_result( - result=result, + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + data = self.decode_counts( + counts=result.counts, metadata=metadata, inverted=inverted, keep_padding=keep_padding, diff --git a/quantumaudio/schemes/qpam.py b/quantumaudio/schemes/qpam.py index 98122eaf..93c962e4 100644 --- a/quantumaudio/schemes/qpam.py +++ b/quantumaudio/schemes/qpam.py @@ -13,12 +13,12 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -27,8 +27,8 @@ class QPAM(Scheme): QPAM class implements encoding and decoding of Digital Audio as Quantum Probability Amplitudes. It's the simplest of Schemes and - uses Qiskit circuit's `initialize` method to set the Quantum States - based on provided values. The values are normalized before encoding + uses state initialisation to set the Quantum States based on + provided values. The values are normalized before encoding using the `convert` method. """ @@ -135,7 +135,7 @@ def prepare_data( def initialize_circuit( self, num_index_qubits: int, num_value_qubits: int - ) -> qiskit.QuantumCircuit: + ) -> CircuitSpec: """Initializes the circuit with Index and Value Registers. Args: @@ -143,39 +143,44 @@ def initialize_circuit( num_value_qubits: Number of qubits used to encode the sample values. Returns: - Qiskit Circuit with the registers + CircuitSpec with the appropriate number of qubits. """ - index_register = qiskit.QuantumRegister( - num_index_qubits, self.labels[0] + total = num_value_qubits + num_index_qubits + spec = CircuitSpec( + num_qubits=total, name=self.__class__.__name__ ) - value_register = qiskit.QuantumRegister( - num_value_qubits, self.labels[1] - ) - # Arranging Registers from Top to Bottom - circuit = qiskit.QuantumCircuit( - value_register, index_register, name=self.__class__.__name__ - ) - return circuit + self._value_range = (0, num_value_qubits) + self._index_range = (num_value_qubits, num_index_qubits) + if num_value_qubits > 0: + spec.metadata["registers"] = { + self.labels[1]: self._value_range, + self.labels[0]: self._index_range, + } + else: + spec.metadata["registers"] = { + self.labels[0]: self._index_range, + } + return spec def value_setting( - self, circuit: qiskit.QuantumCircuit, values: np.ndarray + self, circuit: CircuitSpec, values: np.ndarray ) -> None: """Encodes the prepared, converted values to the initialised circuit. Args: - circuit: Initialized Qiskit Circuit - values: Array of probability amplitudes to encode + circuit: Initialized CircuitSpec. + values: Array of probability amplitudes to encode. """ - circuit.initialize(values) + circuit.initialize(list(values)) - def measure(self, circuit: qiskit.QuantumCircuit) -> None: - """Adds classical measurements to all qubits of the Quantum Circuit if + def measure(self, circuit: CircuitSpec) -> None: + """Adds classical measurements to all qubits of the circuit if the circuit is not already measured. Args: - circuit: Encoded Qiskit Circuit + circuit: Encoded CircuitSpec. """ - if not circuit.cregs: + if circuit.num_clbits == 0: circuit.measure_all() # ----- Default Encode Function ----- @@ -185,8 +190,8 @@ def encode( data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, - ) -> qiskit.QuantumCircuit: - """Given audio data, prepares a Qiskit Circuit representing it. + ) -> CircuitSpec: + """Given audio data, prepares a CircuitSpec representing it. Args: data: Array representing Digital Audio Samples @@ -197,7 +202,7 @@ def encode( - >2: Displays the encoded circuit. Returns: - A Qiskit Circuit representing the Digital Audio + A CircuitSpec representing the Digital Audio. """ utils.validate_data(data) @@ -213,11 +218,11 @@ def encode( # encode values self.value_setting(circuit=circuit, values=values) # additional information for decoding - circuit.metadata = { + circuit.metadata.update({ "num_samples": num_samples, "norm_factor": norm, "scheme": circuit.name, - } + }) if measure: self.measure(circuit) if verbose == 2: @@ -227,7 +232,7 @@ def encode( # ------------------- Decoding Helpers --------------------------- def decode_components( - self, counts: Union[dict, qiskit.result.Counts] + self, counts: dict ) -> np.ndarray: """The first stage of decoding is extracting required components from counts. @@ -244,7 +249,7 @@ def decode_components( def reconstruct_data( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, shots: int, norm: float, ) -> np.ndarray: @@ -258,7 +263,7 @@ def reconstruct_data( norm : the norm factor used to normalize the decoding in QPAM. Return: - Array of restored values + Array of restored values. """ probabilities = self.decode_components(counts) data = self.restore(probabilities, norm, shots) @@ -266,24 +271,24 @@ def reconstruct_data( def decode_counts( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, metadata: dict, shots: Optional[int] = 4000, norm: Optional[float] = None, keep_padding: bool = False, ) -> np.ndarray: - """Given a Qiskit counts object or Dictionary, Extract components and restore the + """Given a counts dictionary, extract components and restore the conversion did at encoding stage. Args: - counts: a qiskit Counts object or Dictionary obtained from a job result. + counts: a counts dictionary obtained from a job result. metadata: metadata required for decoding. shots : total number of times the quantum circuit is measured. norm : Override the norm factor used to normalize the decoding. keep_padding: Undos the padding set at Encoding stage if set to False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ shots = metadata.get("shots", shots) norm = norm if norm else metadata["norm_factor"] @@ -304,17 +309,17 @@ def decode_counts( def decode_result( self, - result: qiskit.result.Result, + result, metadata: Optional[dict] = None, shots: Optional[int] = 8000, norm: Optional[float] = None, keep_padding: bool = False, ) -> np.ndarray: - """Given a Qiskit Result object, Extract components and restore the + """Given a result object, extract components and restore the conversion did at encoding stage. Args: - result: a qiskit Result object that contains counts along + result: a result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. shots : total number of times the quantum circuit is measured. @@ -322,7 +327,7 @@ def decode_result( keep_padding: Undos the padding set at Encoding stage if set to False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata @@ -340,38 +345,35 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, shots: Optional[int] = 8000, norm: Optional[float] = None, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + backend=None, **kwargs, ) -> np.ndarray: - """Given a qiskit circuit, decodes and returns back the Original Audio Array. + """Given a CircuitSpec, decodes and returns back the Original Audio Array. Args: - circuit: A Qiskit Circuit representing the Digital Audio. + circuit: A CircuitSpec representing the Digital Audio. metadata: optionally pass metadata as argument. shots : Total number of times the quantum circuit is measured. norm : The norm factor used to normalize the decoding in QPAM. keep_padding: Undo the padding set at Encoding stage if set to False. - execute_function: Function to execute the circuit for decoding. - - - Defaults to :ref:`utils.execute ` which accepts any additional `**kwargs`. - - The keyword argument **shots** (int) is a metadata for QPAM decoding and accepted - by `execute_function`. (Defaults to **8000**) + backend: Backend name to execute on (default: "qiskit"). Return: - Array of decoded values + Array of decoded values. """ self.measure(circuit) - kwargs["shots"] = shots - result = execute_function(circuit=circuit, **kwargs) - data = self.decode_result( - result=result, + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=shots) + if metadata is None: + metadata = circuit.metadata + metadata["shots"] = shots + data = self.decode_counts( + counts=result.counts, metadata=metadata, shots=shots, norm=norm, diff --git a/quantumaudio/schemes/qsm.py b/quantumaudio/schemes/qsm.py index ca0b037a..7a6a5ff9 100644 --- a/quantumaudio/schemes/qsm.py +++ b/quantumaudio/schemes/qsm.py @@ -13,13 +13,13 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np -import qiskit from bitstring import BitArray from quantumaudio import utils +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -46,13 +46,11 @@ def __init__(self, qubit_depth: Optional[int] = None) -> None: n_fold: Term for a fixed number of indexed registers used. labels: Name of the Quantum registers positions: Index position of Quantum registers - (In Qiskit circuit the registers are arranged - from Top to Bottom) convert: Function that applies a mathematical conversion of input at Encoding. restore: Function that restores the conversion at Decoding. - + keys: Reference to essential metadata keys for decoding. Args: @@ -144,7 +142,7 @@ def prepare_data( def initialize_circuit( self, num_index_qubits: int, num_value_qubits: int - ) -> qiskit.QuantumCircuit: + ) -> CircuitSpec: """Initializes the circuit with Index and Value Registers. Args: @@ -152,24 +150,27 @@ def initialize_circuit( num_value_qubits: Number of qubits used to encode the sample values. Returns: - Qiskit Circuit with the registers + CircuitSpec with the appropriate number of qubits. """ - index_register = qiskit.QuantumRegister( - num_index_qubits, self.labels[0] - ) - value_register = qiskit.QuantumRegister( - num_value_qubits, self.labels[1] - ) - # Arranging Registers from Top to Bottom - circuit = qiskit.QuantumCircuit( - value_register, index_register, name=self.__class__.__name__ + total = num_value_qubits + num_index_qubits + spec = CircuitSpec( + num_qubits=total, name=self.__class__.__name__ ) - circuit.h(index_register) - return circuit + self._value_range = (0, num_value_qubits) + self._index_range = (num_value_qubits, num_index_qubits) + spec.metadata["registers"] = { + self.labels[1]: self._value_range, + self.labels[0]: self._index_range, + } + # Apply Hadamard to index register. + idx_start, idx_size = self._index_range + for q in range(idx_start, idx_start + idx_size): + spec.h(q) + return spec @utils.with_indexing def value_setting( - self, circuit: qiskit.QuantumCircuit, index: int, value: float + self, circuit: CircuitSpec, index: int, value: float ) -> None: """Encodes the prepared, converted values to the initialised circuit. @@ -178,24 +179,26 @@ def value_setting( corresponding to the given index. Args: - circuit: Initialized Qiskit Circuit - index: position to set the value - value: value to be set at the index + circuit: Initialized CircuitSpec. + index: position to set the value. + value: value to be set at the index. """ - value_register, index_register = circuit.qregs - for i, areg_qubit in enumerate(value_register): + v_start, v_size = self._value_range + idx_start, idx_size = self._index_range + controls = list(range(idx_start, idx_start + idx_size)) + for i in range(v_size): a_bit = (value >> i) & 1 if a_bit: - circuit.mcx(index_register, areg_qubit) + circuit.mcx(controls, v_start + i) - def measure(self, circuit: qiskit.QuantumCircuit) -> None: - """Adds classical measurements to all registers of the Quantum Circuit + def measure(self, circuit: CircuitSpec) -> None: + """Adds classical measurements to all qubits of the circuit if the circuit is not already measured. Args: - circuit: Encoded Qiskit Circuit + circuit: Encoded CircuitSpec. """ - if not circuit.cregs: + if circuit.num_clbits == 0: circuit.barrier() circuit.measure_all() @@ -206,8 +209,8 @@ def encode( data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, - ) -> qiskit.QuantumCircuit: - """Given an audio data, prepares a Qiskit Circuit representing it. + ) -> CircuitSpec: + """Given an audio data, prepares a CircuitSpec representing it. Args: data: Array representing Digital Audio Samples @@ -218,7 +221,7 @@ def encode( - >2: Displays the encoded circuit. Returns: - A Qiskit Circuit representing the Digital Audio + A CircuitSpec representing the Digital Audio. """ utils.validate_data(data) @@ -236,11 +239,11 @@ def encode( self.value_setting(circuit=circuit, index=i, value=sample) # additional information for decoding - circuit.metadata = { + circuit.metadata.update({ "num_samples": num_samples, "qubit_shape": (num_index_qubits, num_value_qubits), "scheme": circuit.name, - } + }) # measure, print and return if measure: @@ -253,8 +256,8 @@ def encode( def decode_components( self, - counts: Union[dict, qiskit.result.Counts], - qubit_shape: [int, int], + counts: dict, + qubit_shape: Tuple[int, int], ) -> np.ndarray: """The first stage of decoding is extracting required components from counts. @@ -280,7 +283,7 @@ def decode_components( return data def reconstruct_data( - self, counts: Union[dict, qiskit.result.Counts], qubit_shape: int + self, counts: dict, qubit_shape: Tuple[int, int] ) -> np.ndarray: """Given counts, Extract components and restore the conversion did at encoding stage. @@ -289,10 +292,9 @@ def reconstruct_data( counts: a dictionary with the outcome of measurements performed on the quantum circuit. qubit_shape: Tuple to determine the number of components to get. - qubit_depth : number of qubits in amplitude register. Return: - Array of restored values + Array of restored values. """ data = self.decode_components(counts, qubit_shape) data = self.restore(data, bit_depth=qubit_shape[-1]) @@ -300,28 +302,24 @@ def reconstruct_data( def decode_counts( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, metadata: dict, keep_padding: bool = False, ) -> np.ndarray: - """Given a result object. Extract components and restore the conversion - did in encoding stage. + """Given a counts dictionary, extract components and restore the + conversion did in encoding stage. Args: - counts: a qiskit Counts object or Dictionary obtained from a job result. + counts: a counts dictionary obtained from a job result. metadata: metadata required for decoding. keep_padding: Undo the padding set at Encoding stage if set False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ - index_position, amplitude_position = self.positions qubit_shape = metadata["qubit_shape"] - - # decoding x-axis original_num_samples = metadata["num_samples"] - # decoding y-axis data = self.reconstruct_data(counts, qubit_shape) # undo padding @@ -331,21 +329,21 @@ def decode_counts( def decode_result( self, - result: qiskit.result.Result, + result, metadata: Optional[dict] = None, keep_padding: bool = False, ) -> np.ndarray: - """Given a result object. Extract components and restore the conversion - did in encoding stage. + """Given a result object, extract components and restore the + conversion did in encoding stage. Args: - result: a qiskit Result object that contains counts along + result: a result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata @@ -358,30 +356,31 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + backend=None, **kwargs, ) -> np.ndarray: - """Given a qiskit circuit, decodes and returns back the Original Audio Array. + """Given a CircuitSpec, decodes and returns back the Original Audio Array. Args: - circuit: A Qiskit Circuit representing the Digital Audio. + circuit: A CircuitSpec representing the Digital Audio. metadata: optionally pass metadata as argument. keep_padding: Undo the padding set at Encoding stage if set False. - execute_function: Function to execute the circuit for decoding. - - - Defaults to :ref:`utils.execute ` which accepts any additional `**kwargs`. + backend: Backend name to execute on (default: "qiskit"). Return: - Array of decoded values + Array of decoded values. """ self.measure(circuit) - result = execute_function(circuit=circuit, **kwargs) - data = self.decode_result( - result=result, metadata=metadata, keep_padding=keep_padding + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + data = self.decode_counts( + counts=result.counts, + metadata=metadata, + keep_padding=keep_padding, ) return data diff --git a/quantumaudio/schemes/sqpam.py b/quantumaudio/schemes/sqpam.py index e127f989..7b18a708 100644 --- a/quantumaudio/schemes/sqpam.py +++ b/quantumaudio/schemes/sqpam.py @@ -13,12 +13,12 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -45,12 +45,10 @@ def __init__(self) -> None: n_fold: Term for a fixed number of indexed registers used. labels: Name of the Quantum registers positions: Index position of Quantum registers - (In Qiskit circuit the registers are arranged - from Top to Bottom) convert: Function that applies a mathematical conversion of input at Encoding. restore: Function that restores the conversion at Decoding. - + keys: Reference to essential metadata keys for decoding. """ self.name = "Single-Qubit Probability Amplitude Modulation" @@ -132,7 +130,7 @@ def prepare_data( def initialize_circuit( self, num_index_qubits: int, num_value_qubits: int - ) -> qiskit.QuantumCircuit: + ) -> CircuitSpec: """Initializes the circuit with Index and Value Registers. Args: @@ -140,24 +138,27 @@ def initialize_circuit( num_value_qubits: Number of qubits used to encode the sample values. Returns: - Qiskit Circuit with the registers + CircuitSpec with the appropriate number of qubits. """ - index_register = qiskit.QuantumRegister( - num_index_qubits, self.labels[0] - ) - value_register = qiskit.QuantumRegister( - num_value_qubits, self.labels[1] + total = num_value_qubits + num_index_qubits + spec = CircuitSpec( + num_qubits=total, name=self.__class__.__name__ ) - # Arranging Registers from Top to Bottom - circuit = qiskit.QuantumCircuit( - value_register, index_register, name=self.__class__.__name__ - ) - circuit.h(index_register) - return circuit + self._value_range = (0, num_value_qubits) + self._index_range = (num_value_qubits, num_index_qubits) + spec.metadata["registers"] = { + self.labels[1]: self._value_range, + self.labels[0]: self._index_range, + } + # Apply Hadamard to index register. + idx_start, idx_size = self._index_range + for q in range(idx_start, idx_start + idx_size): + spec.h(q) + return spec @utils.with_indexing def value_setting( - self, circuit: qiskit.QuantumCircuit, index: int, value: float + self, circuit: CircuitSpec, index: int, value: float ) -> None: """Encodes the prepared, converted values to the initialised circuit. This function is used to set a single value at a single index. The @@ -165,36 +166,24 @@ def value_setting( corresponding to the given index. Args: - circuit: Initialized Qiskit Circuit - index: position to set the value - value: value to be set at the index + circuit: Initialized CircuitSpec. + index: position to set the value. + value: value to be set at the index. """ - value_register, index_register = circuit.qregs - - # initialise sub-circuit - sub_circuit = qiskit.QuantumCircuit(name=f"Sample {index}") - sub_circuit.add_register(value_register) - - # rotate qubits with values - for i in range(value_register.size): - sub_circuit.ry(2 * value, i) - - # entangle with index qubits - sub_circuit = sub_circuit.control(index_register.size) - - # attach sub-circuit - circuit.append( - sub_circuit, list(i for i in range(circuit.num_qubits - 1, -1, -1)) - ) - - def measure(self, circuit: qiskit.QuantumCircuit) -> None: - """Adds classical measurements to all registers of the Quantum Circuit + v_start, v_size = self._value_range + idx_start, idx_size = self._index_range + controls = list(range(idx_start, idx_start + idx_size)) + for i in range(v_size): + circuit.mcry(2 * value, controls, v_start + i) + + def measure(self, circuit: CircuitSpec) -> None: + """Adds classical measurements to all qubits of the circuit if the circuit is not already measured. Args: - circuit: Encoded Qiskit Circuit + circuit: Encoded CircuitSpec. """ - if not circuit.cregs: + if circuit.num_clbits == 0: circuit.measure_all() # ----- Default Encode Function ----- @@ -204,8 +193,8 @@ def encode( data: np.ndarray, measure: bool = True, verbose: Union[int, bool] = 1, - ) -> qiskit.QuantumCircuit: - """Given an audio data, prepares a Qiskit Circuit representing it. + ) -> CircuitSpec: + """Given an audio data, prepares a CircuitSpec representing it. Args: data: Array representing Digital Audio Samples @@ -216,7 +205,7 @@ def encode( - >2: Displays the encoded circuit. Returns: - A Qiskit Circuit representing the Digital Audio + A CircuitSpec representing the Digital Audio. """ utils.validate_data(data) @@ -233,24 +222,24 @@ def encode( for i, value in enumerate(values): self.value_setting(circuit=circuit, index=i, value=value) # additional information for decoding - circuit.metadata = { + circuit.metadata.update({ "num_samples": num_samples, "qubit_shape": (num_index_qubits, num_value_qubits), "scheme": circuit.name, - } + }) # measure, print and return if measure: self.measure(circuit) if verbose == 2: - utils.draw_circuit(circuit, decompose=1) + utils.draw_circuit(circuit) return circuit # ------------------- Decoding Helpers --------------------------- def decode_components( self, - counts: Union[dict, qiskit.result.Counts], - qubit_shape: [int, int], + counts: dict, + qubit_shape: Tuple[int, int], ) -> np.ndarray: """The first stage of decoding is extracting required components from counts. @@ -283,8 +272,8 @@ def decode_components( def reconstruct_data( self, - counts: Union[dict, qiskit.result.Counts], - qubit_shape: [int, int], + counts: dict, + qubit_shape: Tuple[int, int], inverted: bool = False, ) -> np.ndarray: """Given counts, Extract components and restore the conversion did at @@ -297,7 +286,7 @@ def reconstruct_data( inverted : retrieves cosine components of the signal. Return: - Array of restored values + Array of restored values. """ cosine_amps, sine_amps = self.decode_components(counts, qubit_shape) data = self.restore(cosine_amps, sine_amps, inverted) @@ -305,32 +294,28 @@ def reconstruct_data( def decode_counts( self, - counts: Union[dict, qiskit.result.Counts], + counts: dict, metadata: dict, inverted: bool = False, keep_padding: bool = False, ) -> np.ndarray: - """Given a Qiskit counts object or Dictionary, Extract components and restore the + """Given a counts dictionary, extract components and restore the conversion did at encoding stage. Args: - counts: a qiskit Counts object or Dictionary obtained from a job result. + counts: a counts dictionary obtained from a job result. metadata: metadata required for decoding. inverted: retrieves cosine components of the signal. keep_padding: Undo the padding set at Encoding stage if set False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ - # decoding x-axis - index_position, _ = self.positions qubit_shape = metadata["qubit_shape"] - original_num_samples = metadata["num_samples"] - # decoding y-axis data = self.reconstruct_data( - counts=counts, qubit_shape=qubit_shape, inverted=False + counts=counts, qubit_shape=qubit_shape, inverted=inverted ) # undo padding @@ -341,23 +326,23 @@ def decode_counts( def decode_result( self, - result: qiskit.result.Result, + result, metadata: Optional[dict] = None, inverted: bool = False, keep_padding: bool = False, ) -> np.ndarray: - """Given a result object. Extract components and restore the conversion - did in encoding stage. + """Given a result object, extract components and restore the + conversion did in encoding stage. Args: - result: a qiskit Result object that contains counts along + result: a result object that contains counts along with metadata that was held by the original circuit. metadata: optionally pass metadata as argument. inverted: retrieves cosine components of the signal. keep_padding: Undo the padding set at Encoding stage if set False. Return: - Array of restored values with original dimensions + Array of restored values with original dimensions. """ counts = utils.get_counts(result) metadata = utils.get_metadata(result) if not metadata else metadata @@ -374,33 +359,32 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, inverted: bool = False, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + backend=None, **kwargs, ) -> np.ndarray: - """Given a qiskit circuit, decodes and returns back the Original Audio Array. + """Given a CircuitSpec, decodes and returns back the Original Audio Array. Args: - circuit: A Qiskit Circuit representing the Digital Audio. + circuit: A CircuitSpec representing the Digital Audio. metadata: optionally pass metadata as argument. inverted: retrieves cosine components of the signal. keep_padding: Undo the padding set at Encoding stage if set False. - execute_function: Function to execute the circuit for decoding. - - - Defaults to :ref:`utils.execute ` which accepts any additional `**kwargs`. + backend: Backend name to execute on (default: "qiskit"). Return: - Array of decoded values + Array of decoded values. """ self.measure(circuit) - result = execute_function(circuit=circuit, **kwargs) - data = self.decode_result( - result=result, + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + data = self.decode_counts( + counts=result.counts, metadata=metadata, inverted=inverted, keep_padding=keep_padding, diff --git a/quantumaudio/utils/circuit.py b/quantumaudio/utils/circuit.py index 4411457c..83230518 100644 --- a/quantumaudio/utils/circuit.py +++ b/quantumaudio/utils/circuit.py @@ -16,29 +16,38 @@ from functools import wraps from typing import Callable -import qiskit +from quantumaudio.backends.core.circuit import CircuitSpec # ========================= # Circuit Preparation Utils # ========================= -def apply_x_at_index(qc: qiskit.QuantumCircuit, i: int) -> None: - """This function is used to encode an index value into control qubits of a circuit. +def apply_x_at_index( + spec: CircuitSpec, scheme, i: int +) -> None: + """Encode an index value into control qubits of a circuit. + + Applies X gates to each control qubit whose corresponding + bit in *i* is 0, preparing the control state for a + multi-controlled operation at index *i*. Args: - qc: Qiskit Circuit - i: Index position + spec: The circuit specification. + scheme: The scheme instance (provides register ranges). + i: Index position. """ - if len(qc.qregs) != 2: - _, creg, treg = qc.qregs - else: - _, treg = qc.qregs - creg = [] - for reg_index, reg_qubit in enumerate(creg[:] + treg[:]): + control_qubits = [] + if hasattr(scheme, "_channel_range"): + ch_start, ch_size = scheme._channel_range + control_qubits.extend(range(ch_start, ch_start + ch_size)) + idx_start, idx_size = scheme._index_range + control_qubits.extend(range(idx_start, idx_start + idx_size)) + + for reg_index, qubit in enumerate(control_qubits): bit = (i >> reg_index) & 1 if not bit: - qc.x(reg_qubit) + spec.x(qubit) def with_indexing(func: Callable) -> Callable: @@ -51,13 +60,13 @@ def with_indexing(func: Callable) -> Callable: The wrapped function with time indexing applied. """ - @wraps(func) # added to fix docstrings not printing func - def wrapper(*args, **kwargs): - qc = kwargs.get("circuit") + @wraps(func) + def wrapper(self, *args, **kwargs): + spec = kwargs.get("circuit") i = kwargs.get("index") - qc.barrier() - apply_x_at_index(qc, i) - func(*args, **kwargs) - apply_x_at_index(qc, i) + spec.barrier() + apply_x_at_index(spec, self, i) + func(self, *args, **kwargs) + apply_x_at_index(spec, self, i) return wrapper diff --git a/quantumaudio/utils/convert.py b/quantumaudio/utils/convert.py index 838f5ef0..970ccd6c 100644 --- a/quantumaudio/utils/convert.py +++ b/quantumaudio/utils/convert.py @@ -13,6 +13,8 @@ # limitations under the License. # ========================================================================== +import numbers + import numpy as np # ====================== @@ -58,15 +60,39 @@ def convert_to_angles(array: np.ndarray) -> np.ndarray: def quantize(array: np.ndarray, qubit_depth: int) -> np.ndarray: """Quantizes the array to a given qubit depth. + Values outside the representable signed-integer range + ``[-2**(qubit_depth - 1), 2**(qubit_depth - 1) - 1]`` are saturated + rather than wrapped, mirroring the standard audio-clipping + convention. Without this, an input of ``+1.0`` at ``qubit_depth=3`` + would produce integer ``+4``, which does not fit in a 3-bit signed + register and wraps to ``-4`` (decoded ``-1.0``): a sign-flip glitch + on every peak sample. + Args: array: The input array. - qubit_depth: The number of bits to quantize to. + qubit_depth: The number of bits to quantize to. Must be an + integer >= 1. Returns: The quantized array as integers. + + Raises: + ValueError: If ``qubit_depth`` is not an integer >= 1. """ - values = array * (2 ** (qubit_depth - 1)) - return values.astype(int) + if ( + not isinstance(qubit_depth, numbers.Integral) + or isinstance(qubit_depth, bool) + or qubit_depth < 1 + ): + raise ValueError( + f"qubit_depth must be an integer >= 1, got {qubit_depth!r}." + ) + # Coerce to Python int so the arithmetic below cannot overflow a + # narrow NumPy integer dtype (e.g. ``np.uint8``). + qubit_depth = int(qubit_depth) + scale = 2 ** (qubit_depth - 1) + values = array * scale + return np.clip(values, -scale, scale - 1).astype(np.int64) def convert_from_probability_amplitudes( diff --git a/quantumaudio/utils/execute.py b/quantumaudio/utils/execute.py index e23afed5..3de236dc 100644 --- a/quantumaudio/utils/execute.py +++ b/quantumaudio/utils/execute.py @@ -37,6 +37,7 @@ def execute( backend: Any = None, keep_memory: bool = False, optimization_level: int = 3, + **kwargs: Any, ): """ Executes a quantum circuit on a given backend and return the results. @@ -47,6 +48,9 @@ def execute( shots: Total number of times the quantum circuit is measured. keep_memory: Whether to return the memory (quantum state) of each shot. optimization_level: Optimization level for transpiling the circuit. + **kwargs: Accepted for compatibility with the :class:`ExecuteFunction` + protocol (additional keyword arguments forwarded by scheme + ``decode`` calls); ignored by this implementation. Returns: Result: The result of the execution, containing the counts and other metadata. @@ -74,6 +78,7 @@ def execute_with_sampler( backend: Any = None, shots: int = 8000, optimization_level: int = 3, + **kwargs: Any, ): """ Executes a quantum circuit on a given backend using `Sampler Primitive` and return the results. @@ -83,6 +88,9 @@ def execute_with_sampler( backend: The backend on which to run the circuit. If None, the default backend `qiskit_aer.AerSimulator()` is used. shots: Total number of times the quantum circuit is measured. optimization_level: Optimization level for transpiling the circuit. + **kwargs: Accepted for compatibility with the :class:`ExecuteFunction` + protocol (additional keyword arguments forwarded by scheme + ``decode`` calls); ignored by this implementation. Returns: Result: The result of the execution, containing the counts and other metadata. diff --git a/quantumaudio/utils/preview.py b/quantumaudio/utils/preview.py index 0be8b84d..975f53ac 100644 --- a/quantumaudio/utils/preview.py +++ b/quantumaudio/utils/preview.py @@ -14,7 +14,8 @@ # ========================================================================== import matplotlib.pyplot as plt -import qiskit + +from quantumaudio.backends import CircuitSpec, get_backend # ====================== # Preview Functions @@ -36,20 +37,24 @@ def print_num_qubits( print(f"{qubits} qubits for {labels[i]}") -def draw_circuit(circuit: qiskit.QuantumCircuit, decompose: int = 0) -> None: +def draw_circuit(circuit, decompose: int = 0) -> None: """Draws a quantum circuit diagram. Args: - circuit: The quantum circuit to draw. + circuit: The quantum circuit or CircuitSpec to draw. decompose: Number of times to decompose the circuit. Defaults to 0. """ + if isinstance(circuit, CircuitSpec): + backend = get_backend("qiskit") + circuit = backend.build_circuit(circuit) + for _i in range(decompose): circuit = circuit.decompose() fig = circuit.draw("mpl", style="clifford") - try: # Check if the code is running in Jupyter Notebook + try: # Check if the code is running in Jupyter Notebook. display(fig) except NameError: plt.show() diff --git a/quantumaudio/utils/results.py b/quantumaudio/utils/results.py index 4b6be2dd..a4944e85 100644 --- a/quantumaudio/utils/results.py +++ b/quantumaudio/utils/results.py @@ -14,9 +14,13 @@ # ========================================================================== from typing import Union + import qiskit from qiskit.primitives import PrimitiveResult, SamplerPubResult +from quantumaudio.backends.core.circuit import CircuitSpec +from quantumaudio.backends.core.result import UnifiedResult + # ====================== # Post-processing # ====================== @@ -44,7 +48,8 @@ def get_counts(results_obj, result_id=0): Extract counts from a results object. Args: - results_obj: An instance of `PrimitiveResult` or `Result` object from which to extract counts. + results_obj: An instance of `UnifiedResult`, `PrimitiveResult`, + or `Result` object from which to extract counts. result_id: The index of the result to extract if the results object contains multiple results. Returns: @@ -52,7 +57,10 @@ def get_counts(results_obj, result_id=0): """ counts = {} - if isinstance(results_obj, PrimitiveResult): + if isinstance(results_obj, UnifiedResult): + counts = results_obj.counts + + elif isinstance(results_obj, PrimitiveResult): results_obj = results_obj[result_id] if isinstance(results_obj, SamplerPubResult): @@ -61,7 +69,7 @@ def get_counts(results_obj, result_id=0): elif isinstance(results_obj, qiskit.result.Result): counts = results_obj.get_counts() - else: + elif not counts: raise TypeError("Unsupported result object type.") return counts @@ -72,7 +80,8 @@ def get_metadata(results_obj, result_id=0): Extract metadata from a results object. Args: - results_obj: An instance of `PrimitiveResult` or `Result` object from which to extract metadata. + results_obj: An instance of `UnifiedResult`, `PrimitiveResult`, + or `Result` object from which to extract metadata. result_id: The index of the result to extract if the results object contains multiple results. Returns: @@ -80,6 +89,9 @@ def get_metadata(results_obj, result_id=0): """ metadata = {} + if isinstance(results_obj, UnifiedResult): + return dict(results_obj.metadata) + if isinstance(results_obj, PrimitiveResult): metadata.update(results_obj.metadata) results_obj = results_obj[result_id] @@ -134,22 +146,26 @@ def pick_key_from_instance(instance, key): """Search for given key in an instance used at decoding. Args: - instance: Can be Qiskit Circuit or Result object. + instance: Can be CircuitSpec, Qiskit Circuit, or Result object. key: Key to find in the encoded metadata. """ - if isinstance(instance, qiskit.circuit.QuantumCircuit): - if key == "scheme" and instance.name.upper() in [ - "QPAM", - "SQPAM", - "QSM", - "MSQPAM", - "MQSM", - ]: + _SCHEME_NAMES = ["QPAM", "SQPAM", "QSM", "MSQPAM", "MQSM"] + + # CircuitSpec path. + if isinstance(instance, CircuitSpec): + if key == "scheme" and instance.name.upper() in _SCHEME_NAMES: return instance.name.upper() elif key in instance.metadata: return instance.metadata[key] + # Legacy Qiskit QuantumCircuit path. + elif isinstance(instance, qiskit.circuit.QuantumCircuit): + if key == "scheme" and instance.name.upper() in _SCHEME_NAMES: + return instance.name.upper() + elif instance.metadata and key in instance.metadata: + return instance.metadata[key] + elif isinstance( instance, (qiskit.result.Result, PrimitiveResult, SamplerPubResult) ): @@ -157,9 +173,9 @@ def pick_key_from_instance(instance, key): if key in metadata: return metadata[key] - # If the key was not found in the instance + # If the key was not found in the instance. if key == "scheme": - raise ValueError(f"{key} is missing") # Scheme is essential + raise ValueError(f"{key} is missing") # Scheme is essential. return None diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 00000000..60dd63bf --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,31 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Shared test helpers.""" + +from qiskit.result.counts import Counts + + +def counts_from_spaced(d: dict) -> Counts: + """Build a Qiskit ``Counts`` from a dict whose keys may include spaces + between register segments. + + The backend layer's ``UnifiedResult.counts`` uses flat bitstrings, but + spaces in fixture literals make the register boundaries visible at a + glance (e.g. ``"101 100"`` for amplitude/time). This helper strips + those spaces so the fixture reads naturally while still producing the + flat-string format the schemes expect. + """ + return Counts({k.replace(" ", ""): v for k, v in d.items()}) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 00000000..6f3245da --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,695 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +"""Tests for the framework-agnostic backend abstractions.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from quantumaudio.backends import ( + CircuitSpec, + GateOp, + GateType, + UnifiedResult, + available_backends, + get_backend, + is_available, + registry, + require, +) + + +# ====================================================================== +# UnifiedResult +# ====================================================================== + + +def test_probabilities_basic(): + res = UnifiedResult( + counts={"00": 30, "01": 10, "11": 60}, + shots=100, + backend_name="test", + ) + probs = res.probabilities() + assert probs == {"00": 0.3, "01": 0.1, "11": 0.6} + + +def test_probabilities_array_indexed_by_int(): + res = UnifiedResult( + counts={"00": 25, "01": 25, "10": 25, "11": 25}, + shots=100, + backend_name="test", + ) + arr = res.probabilities_array() + np.testing.assert_allclose(arr, [0.25, 0.25, 0.25, 0.25]) + + +def test_probabilities_array_empty_counts(): + res = UnifiedResult(counts={}, shots=10, backend_name="test") + arr = res.probabilities_array() + assert arr.size == 0 + + +def test_shots_must_be_positive(): + with pytest.raises(ValueError, match="shots must be positive"): + UnifiedResult(counts={"0": 1}, shots=0, backend_name="t") + with pytest.raises(ValueError): + UnifiedResult(counts={"0": 1}, shots=-5, backend_name="t") + + +def test_marginal_keeps_only_requested_qubit(): + # 2-qubit distribution (qubit 0 is the rightmost char). + res = UnifiedResult( + counts={"00": 5, "01": 3, "10": 2, "11": 4}, + shots=14, + backend_name="test", + ) + # Marginalising over qubit 0 collapses on the rightmost bit. + m0 = res.marginal([0]) + assert m0.counts == {"0": 7, "1": 7} + # Marginalising over qubit 1 collapses on the leftmost bit. + m1 = res.marginal([1]) + assert m1.counts == {"0": 8, "1": 6} + + +def test_marginal_preserves_total_shots_and_metadata(): + res = UnifiedResult( + counts={"00": 2, "01": 3, "10": 4, "11": 1}, + shots=10, + backend_name="test", + metadata={"scheme": "fake"}, + ) + m = res.marginal([0]) + assert sum(m.counts.values()) == 10 + assert m.shots == 10 + assert m.backend_name == "test" + assert m.metadata == {"scheme": "fake"} + + +def test_marginal_preserves_qubit_order_in_key(): + # marginal([0, 1]) should keep both bits in their original order. + res = UnifiedResult( + counts={"00": 1, "01": 2, "10": 3, "11": 4}, + shots=10, + backend_name="test", + ) + m = res.marginal([0, 1]) + assert m.counts == {"00": 1, "01": 2, "10": 3, "11": 4} + + +def test_marginal_empty_counts(): + res = UnifiedResult(counts={}, shots=5, backend_name="t") + m = res.marginal([0]) + assert m.counts == {} + + +def test_to_qiskit_result_round_trips_counts(): + """to_qiskit_result().get_counts() must preserve bitstring counts.""" + pytest.importorskip("qiskit") + original = {"000": 10, "001": 20, "111": 70} + res = UnifiedResult( + counts=original, shots=100, backend_name="test" + ) + qiskit_result = res.to_qiskit_result() + counts = qiskit_result.get_counts() + # Qiskit's Counts is a dict-like; compare as plain dict. + assert dict(counts) == original + + +def test_to_qiskit_result_with_utils_get_counts(): + """The Qiskit bridge must work with utils.get_counts().""" + pytest.importorskip("qiskit") + from quantumaudio.utils.results import get_counts + + original = {"00": 40, "01": 10, "10": 25, "11": 25} + res = UnifiedResult( + counts=original, shots=100, backend_name="test" + ) + qiskit_result = res.to_qiskit_result() + assert dict(get_counts(qiskit_result)) == original + + +def test_to_qiskit_result_handles_empty_counts(): + pytest.importorskip("qiskit") + res = UnifiedResult(counts={}, shots=10, backend_name="t") + qiskit_result = res.to_qiskit_result() + assert dict(qiskit_result.get_counts()) == {} + + +# ====================================================================== +# CircuitSpec: clbits and measure semantics +# ====================================================================== + + +def test_measure_uses_clbits_field_not_params(): + spec = CircuitSpec(num_qubits=2) + spec.measure(0, 1) + op = spec.ops[-1] + assert op.gate is GateType.MEASURE + assert op.qubits == (0,) + assert op.clbits == (1,) + # Crucially, params stays the float-only angle channel. + assert op.params == () + + +def test_measure_grows_num_clbits(): + spec = CircuitSpec(num_qubits=4) + assert spec.num_clbits == 0 + spec.measure(0, 0) + assert spec.num_clbits == 1 + spec.measure(1, 7) + assert spec.num_clbits == 8 + # A smaller index must not shrink num_clbits. + spec.measure(2, 3) + assert spec.num_clbits == 8 + + +def test_measure_all_sets_num_clbits(): + spec = CircuitSpec(num_qubits=3) + spec.measure_all() + assert spec.num_clbits == 3 + measures = [ + op for op in spec.ops if op.gate is GateType.MEASURE + ] + assert len(measures) == 3 + assert [m.clbits for m in measures] == [(0,), (1,), (2,)] + + +def test_gate_op_default_clbits_is_empty(): + op = GateOp(GateType.H, (0,)) + assert op.clbits == () + + +# ====================================================================== +# CircuitSpec.initialize: input validation +# ====================================================================== + + +def test_initialize_wrong_length_raises_value_error(): + spec = CircuitSpec(num_qubits=2) + with pytest.raises(ValueError, match="amplitudes"): + spec.initialize([1.0, 0.0, 0.0]) # 3 != 2**2 + + +def test_initialize_zero_state_is_no_op(): + spec = CircuitSpec(num_qubits=2) + spec.initialize([0.0, 0.0, 0.0, 0.0]) + assert spec.ops == [] + + +# ====================================================================== +# CircuitSpec.initialize: Mottonen state-prep correctness +# ====================================================================== + + +# A tiny statevector simulator restricted to the gate types emitted by +# the Mottonen decomposition (RY, CX). Lets us check correctness of the +# decomposition without committing to a particular provider backend. + +def _apply_ry(state: np.ndarray, theta: float, qubit: int) -> np.ndarray: + n = int(np.log2(state.size)) + c, s = np.cos(theta / 2), np.sin(theta / 2) + new = np.zeros_like(state) + for i in range(state.size): + bit = (i >> qubit) & 1 + partner = i ^ (1 << qubit) + if bit == 0: + # |0> on qubit: amplitude c*a0 - s*a1. + new[i] += c * state[i] - s * state[partner] + else: + new[i] += s * state[partner] + c * state[i] + return new + + +def _apply_cx( + state: np.ndarray, control: int, target: int +) -> np.ndarray: + new = state.copy() + for i in range(state.size): + if (i >> control) & 1: + j = i ^ (1 << target) + if i < j: + new[i], new[j] = state[j], state[i] + return new + + +def _simulate(spec: CircuitSpec) -> np.ndarray: + state = np.zeros(2**spec.num_qubits) + state[0] = 1.0 + for op in spec.ops: + if op.gate is GateType.RY: + state = _apply_ry(state, op.params[0], op.qubits[0]) + elif op.gate is GateType.CX: + state = _apply_cx(state, op.qubits[0], op.qubits[1]) + else: + raise AssertionError( + f"Mottonen decomposition emitted unexpected gate " + f"{op.gate}; only RY and CX are expected." + ) + return state + + +@pytest.mark.parametrize("n", [1, 2, 3]) +def test_initialize_basis_states(n): + """Each computational-basis state should round-trip exactly.""" + for k in range(2**n): + target = np.zeros(2**n) + target[k] = 1.0 + spec = CircuitSpec(num_qubits=n) + spec.initialize(target) + result = _simulate(spec) + np.testing.assert_allclose(result, target, atol=1e-10) + + +@pytest.mark.parametrize("n", [1, 2, 3]) +def test_initialize_uniform_superposition(n): + target = np.full(2**n, 1.0 / np.sqrt(2**n)) + spec = CircuitSpec(num_qubits=n) + spec.initialize(target) + result = _simulate(spec) + np.testing.assert_allclose(result, target, atol=1e-10) + + +@pytest.mark.parametrize("n", [1, 2, 3]) +def test_initialize_random_nonneg_real_states(n): + """Random non-negative real states (the QPAM use case).""" + rng = np.random.default_rng(seed=42 + n) + for _ in range(5): + target = np.abs(rng.standard_normal(2**n)) + target = target / np.linalg.norm(target) + spec = CircuitSpec(num_qubits=n) + spec.initialize(target) + result = _simulate(spec) + np.testing.assert_allclose(result, target, atol=1e-10) + + +def test_initialize_signed_input_encodes_magnitudes(): + """Signed inputs are encoded as their absolute values (documented + limitation of the magnitude-based Mottonen decomposition).""" + spec = CircuitSpec(num_qubits=2) + signed = np.array([0.5, -0.5, 0.5, -0.5]) + spec.initialize(signed) + result = _simulate(spec) + np.testing.assert_allclose(result, np.abs(signed), atol=1e-10) + + +def test_initialize_normalises_input(): + """Unnormalised input must be auto-normalised before encoding.""" + spec = CircuitSpec(num_qubits=2) + spec.initialize([2.0, 0.0, 0.0, 0.0]) # norm = 2 + result = _simulate(spec) + np.testing.assert_allclose(result, [1.0, 0.0, 0.0, 0.0], atol=1e-10) + + +# ====================================================================== +# Optional-dependency helpers +# ====================================================================== + + +def test_is_available_for_installed_package(): + # numpy is always installed in this project's env. + assert is_available("numpy") is True + + +def test_is_available_for_missing_package(): + assert is_available("definitely_not_a_real_package_xyz") is False + + +def test_require_returns_module_when_present(): + import numpy as expected_np + + got = require("numpy") + assert got is expected_np + + +def test_require_raises_install_hint_when_missing(): + with pytest.raises(ImportError, match="install quantumaudio"): + require("definitely_not_a_real_package_xyz") + + +def test_require_does_not_swallow_nested_import_error(tmp_path, monkeypatch): + """A nested ImportError inside an installed module must propagate.""" + pkg = tmp_path / "broken_pkg" + pkg.mkdir() + (pkg / "__init__.py").write_text( + "import this_module_does_not_exist_either\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + with pytest.raises(ModuleNotFoundError) as excinfo: + require("broken_pkg") + # The nested missing module is the one that should surface, + # not the top-level "broken_pkg". + assert excinfo.value.name == "this_module_does_not_exist_either" + + +# ====================================================================== +# GateType / CircuitSpec builder coverage +# ====================================================================== + + +class TestGateType: + def test_basic_gates_exist(self): + assert GateType.H + assert GateType.X + assert GateType.RY + assert GateType.CX + + def test_extended_gates_exist(self): + assert GateType.MCX + assert GateType.MCRY + assert GateType.BARRIER + + +class TestCircuitSpec: + def test_builder_returns_self(self): + spec = CircuitSpec(2) + result = spec.h(0).cx(0, 1) + assert result is spec + + def test_ops_recorded(self): + spec = CircuitSpec(3) + spec.h(0).x(1).cx(0, 2).barrier() + assert len(spec.ops) == 4 + assert spec.ops[0] == GateOp(GateType.H, (0,)) + assert spec.ops[1] == GateOp(GateType.X, (1,)) + assert spec.ops[2] == GateOp(GateType.CX, (0, 2)) + assert spec.ops[3] == GateOp(GateType.BARRIER, ()) + + def test_mcx(self): + spec = CircuitSpec(4) + spec.mcx([0, 1, 2], 3) + op = spec.ops[0] + assert op.gate == GateType.MCX + assert op.qubits == (0, 1, 2, 3) + + def test_mcry(self): + spec = CircuitSpec(3) + spec.mcry(1.5, [0, 1], 2) + op = spec.ops[0] + assert op.gate == GateType.MCRY + assert op.qubits == (0, 1, 2) + assert op.params == (1.5,) + + def test_initialize_decomposes(self): + """initialize() should produce RY and CX ops, not an INITIALIZE op.""" + spec = CircuitSpec(2) + spec.initialize([0.5, 0.5, 0.5, 0.5]) + gate_types = {op.gate for op in spec.ops} + assert GateType.RY in gate_types or GateType.CX in gate_types + # No raw INITIALIZE gate should appear. + assert all( + op.gate != GateType.MEASURE for op in spec.ops + if hasattr(GateType, "INITIALIZE") + ) + + def test_metadata(self): + spec = CircuitSpec(2, name="test") + spec.metadata["key"] = "value" + assert spec.name == "test" + assert spec.metadata["key"] == "value" + + +# ====================================================================== +# Backend registry +# ====================================================================== + + +class TestRegistry: + def test_qiskit_available(self): + assert "qiskit" in available_backends() + + def test_get_qiskit(self): + be = get_backend("qiskit") + assert be.name == "qiskit" + + def test_unknown_backend_raises(self): + with pytest.raises(KeyError): + get_backend("nonexistent_backend") + + +# ====================================================================== +# Qiskit backend +# ====================================================================== + + +class TestQiskitBackend: + def test_build_and_run(self): + spec = CircuitSpec(2) + spec.h(0).cx(0, 1).measure_all() + be = get_backend("qiskit") + result = be.run_spec(spec, shots=100) + assert isinstance(result, UnifiedResult) + assert sum(result.counts.values()) == 100 + + def test_register_reconstruction(self): + spec = CircuitSpec(4, name="TestCirc") + spec.metadata["registers"] = { + "amp": (0, 1), + "time": (1, 3), + } + spec.h(1).h(2).h(3).measure_all() + be = get_backend("qiskit") + qc = be.build_circuit(spec) + reg_names = [r.name for r in qc.qregs] + assert "amp" in reg_names + assert "time" in reg_names + + def test_statevector(self): + spec = CircuitSpec(1) + spec.h(0) + be = get_backend("qiskit") + native = be.build_circuit(spec) + sv = be.statevector(native) + assert len(sv) == 2 + assert abs(abs(sv[0]) - 1 / np.sqrt(2)) < 1e-6 + + +# ====================================================================== +# Cirq backend +# ====================================================================== + + +@pytest.mark.skipif( + not is_available("cirq"), reason="cirq not installed" +) +class TestCirqBackend: + def test_build_and_run(self): + spec = CircuitSpec(2) + spec.h(0).cx(0, 1).measure_all() + be = get_backend("cirq") + result = be.run_spec(spec, shots=100) + assert isinstance(result, UnifiedResult) + assert sum(result.counts.values()) == 100 + # Bell state: only "00" and "11" should appear. + for key in result.counts: + assert key in ("00", "11") + + def test_mcx(self): + spec = CircuitSpec(3) + spec.x(0).x(1).mcx([0, 1], 2).measure_all() + be = get_backend("cirq") + result = be.run_spec(spec, shots=100) + # All controls are 1, so target should flip to 1. + assert result.counts.get("111", 0) == 100 + + def test_mcry(self): + spec = CircuitSpec(2) + spec.x(0).mcry(np.pi, [0], 1).measure_all() + be = get_backend("cirq") + result = be.run_spec(spec, shots=100) + # Control=1, RY(pi) flips target from |0> to |1>. + assert result.counts.get("11", 0) == 100 + + def test_statevector(self): + spec = CircuitSpec(1) + spec.h(0) + be = get_backend("cirq") + native = be.build_circuit(spec) + sv = be.statevector(native) + assert len(sv) == 2 + assert abs(abs(sv[0]) - 1 / np.sqrt(2)) < 1e-6 + + +# ====================================================================== +# PennyLane backend +# ====================================================================== + + +@pytest.mark.skipif( + not is_available("pennylane"), reason="pennylane not installed" +) +class TestPennyLaneBackend: + def test_build_and_run(self): + spec = CircuitSpec(2) + spec.h(0).cx(0, 1).measure_all() + be = get_backend("pennylane") + result = be.run_spec(spec, shots=100) + assert isinstance(result, UnifiedResult) + assert sum(result.counts.values()) == 100 + # Bell state: only "00" and "11" should appear. + for key in result.counts: + assert key in ("00", "11") + + def test_mcx(self): + spec = CircuitSpec(3) + spec.x(0).x(1).mcx([0, 1], 2).measure_all() + be = get_backend("pennylane") + result = be.run_spec(spec, shots=100) + # All controls are 1, so target should flip to 1. + assert result.counts.get("111", 0) == 100 + + def test_mcry(self): + spec = CircuitSpec(2) + spec.x(0).mcry(np.pi, [0], 1).measure_all() + be = get_backend("pennylane") + result = be.run_spec(spec, shots=100) + # Control=1, RY(pi) flips target from |0> to |1>. + assert result.counts.get("11", 0) == 100 + + def test_statevector(self): + spec = CircuitSpec(1) + spec.h(0) + be = get_backend("pennylane") + native = be.build_circuit(spec) + sv = be.statevector(native) + assert len(sv) == 2 + assert abs(abs(sv[0]) - 1 / np.sqrt(2)) < 1e-6 + + +# ====================================================================== +# CUDA-Q backend +# ====================================================================== + + +@pytest.mark.skipif( + not is_available("cudaq"), reason="cudaq not installed" +) +class TestCudaQBackend: + def test_build_and_run(self): + spec = CircuitSpec(2) + spec.h(0).cx(0, 1).measure_all() + be = get_backend("cudaq") + result = be.run_spec(spec, shots=100) + assert isinstance(result, UnifiedResult) + assert sum(result.counts.values()) == 100 + # Bell state: only "00" and "11" should appear. + for key in result.counts: + assert key in ("00", "11") + + def test_mcx(self): + spec = CircuitSpec(3) + spec.x(0).x(1).mcx([0, 1], 2).measure_all() + be = get_backend("cudaq") + result = be.run_spec(spec, shots=100) + # All controls are 1, so target should flip to 1. + assert result.counts.get("111", 0) == 100 + + def test_mcry(self): + spec = CircuitSpec(2) + spec.x(0).mcry(np.pi, [0], 1).measure_all() + be = get_backend("cudaq") + result = be.run_spec(spec, shots=100) + # Control=1, RY(pi) flips target from |0> to |1>. + assert result.counts.get("11", 0) == 100 + + def test_statevector(self): + spec = CircuitSpec(1) + spec.h(0) + be = get_backend("cudaq") + native = be.build_circuit(spec) + sv = be.statevector(native) + assert len(sv) == 2 + assert abs(abs(sv[0]) - 1 / np.sqrt(2)) < 1e-6 + + def test_remote_target_requires_explicit_selector(self): + """Bare target='ionq' would route to billed hardware -- block it.""" + from quantumaudio.backends.providers.cudaq_backend import ( + CudaQBackend, + ) + with pytest.raises(ValueError, match="qpu="): + CudaQBackend(target="ionq") + with pytest.raises(ValueError, match="machine="): + CudaQBackend(target="quantinuum") + # Explicit selector is accepted (constructor only -- doesn't + # actually submit, so this is offline). + CudaQBackend(target="ionq", qpu="simulator") + CudaQBackend(target="quantinuum", machine="H1-1SC") + + +# ====================================================================== +# Cross-backend scheme round-trips +# ====================================================================== + + +BACKENDS = ["qiskit"] +if is_available("cirq"): + BACKENDS.append("cirq") +if is_available("pennylane"): + BACKENDS.append("pennylane") +if is_available("cudaq"): + BACKENDS.append("cudaq") + + +@pytest.fixture(params=BACKENDS) +def backend_name(request): + return request.param + + +@pytest.fixture +def mono_data(): + return np.array([0.0, -0.25, 0.5, 0.75, -0.75, -1.0, 0.25]) + + +@pytest.fixture +def stereo_data(): + return np.array([ + [0.0, -0.25, 0.5, 0.75, -0.75, -1.0, 0.25], + [0.0, 0.25, -0.5, -0.75, 0.75, 1.0, -0.25], + ]) + + +class TestSchemesCrossBackend: + def _encode_decode(self, scheme_name, data, backend_name, shots=8000): + import quantumaudio + scheme = quantumaudio.load_scheme(scheme_name) + spec = scheme.encode(data, verbose=0) + assert isinstance(spec, CircuitSpec) + decoded = scheme.decode(spec, backend=backend_name, shots=shots) + return decoded + + def test_qpam(self, mono_data, backend_name): + decoded = self._encode_decode("qpam", mono_data, backend_name) + assert decoded.shape == mono_data.shape + # QPAM has inherent quantisation noise; just check it runs. + assert decoded is not None + + def test_sqpam(self, mono_data, backend_name): + decoded = self._encode_decode("sqpam", mono_data, backend_name) + error = np.sum((mono_data - decoded) ** 2) + assert error < 0.5 + + def test_qsm(self, mono_data, backend_name): + decoded = self._encode_decode("qsm", mono_data, backend_name) + assert decoded.shape == mono_data.shape + + def test_msqpam(self, stereo_data, backend_name): + decoded = self._encode_decode("msqpam", stereo_data, backend_name) + assert decoded.shape == stereo_data.shape + + def test_mqsm(self, stereo_data, backend_name): + decoded = self._encode_decode("mqsm", stereo_data, backend_name) + assert decoded.shape == stereo_data.shape diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 00000000..175e8cf6 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,72 @@ +# Copyright 2024 Moth Quantum +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ========================================================================== + +import numpy as np +import pytest + +from quantumaudio.utils.convert import quantize + + +def test_quantize_in_range_unchanged(): + array = np.array([0.0, -0.25, 0.5, 0.75, -0.75, -1.0]) + out = quantize(array, qubit_depth=3) + # qubit_depth=3 -> integer range [-4, 3]; all values map cleanly. + assert out.tolist() == [0, -1, 2, 3, -3, -4] + + +def test_quantize_saturates_positive_overflow(): + # Regression for MQSM/QSM wraparound: input +1.0 at qubit_depth=3 + # used to produce integer +4, which wrote bit pattern 100 into a + # 3-bit signed register and decoded back as -1.0 (sign flip on + # every peak sample). Saturate to the max representable value + # instead. + array = np.array([1.0, 1.5, 2.0]) + out = quantize(array, qubit_depth=3) + assert out.tolist() == [3, 3, 3] + + +def test_quantize_saturates_negative_overflow(): + array = np.array([-1.5, -2.0]) + out = quantize(array, qubit_depth=3) + # qubit_depth=3 -> min representable integer is -4. + assert out.tolist() == [-4, -4] + + +def test_quantize_minus_one_is_representable(): + # -1.0 is the symmetric-looking endpoint but corresponds to the + # most-negative integer; it must round-trip without saturation. + array = np.array([-1.0]) + for depth in (3, 4, 8): + out = quantize(array, qubit_depth=depth) + assert out.tolist() == [-(2 ** (depth - 1))] + + +@pytest.mark.parametrize( + "bad_depth", [0, -1, 1.5, "3", None, True, False, np.int64(0)] +) +def test_quantize_rejects_invalid_qubit_depth(bad_depth): + array = np.array([0.0, 0.5]) + with pytest.raises(ValueError): + quantize(array, qubit_depth=bad_depth) + + +@pytest.mark.parametrize("good_depth", [np.int64(3), np.int32(4), np.uint8(5)]) +def test_quantize_accepts_numpy_integer_depth(good_depth): + # NumPy integer scalars are common when depths come from array + # metadata; they should be accepted alongside built-in ``int``. + array = np.array([0.0, -1.0, 0.5]) + out = quantize(array, qubit_depth=good_depth) + expected = quantize(array, qubit_depth=int(good_depth)) + assert out.tolist() == expected.tolist() \ No newline at end of file diff --git a/tests/test_mqsm.py b/tests/test_mqsm.py index 76f67f0d..76730b92 100644 --- a/tests/test_mqsm.py +++ b/tests/test_mqsm.py @@ -15,13 +15,15 @@ import numpy as np import pytest -from qiskit import QuantumCircuit -from qiskit.result.counts import Counts from qiskit.result.result import Result +from quantumaudio.backends import CircuitSpec + from quantumaudio.schemes import MQSM from quantumaudio.utils import interleave_channels +from _helpers import counts_from_spaced + @pytest.fixture def mqsm(): @@ -180,7 +182,7 @@ def test_initialize_circuit( num_index_qubits, num_channels_qubits, num_value_qubits ) assert circuit != None - assert type(circuit) == QuantumCircuit + assert isinstance(circuit, CircuitSpec) @pytest.fixture @@ -231,15 +233,10 @@ def test_circuit_registers( prepared_circuit.num_clbits == num_index_qubits + num_value_qubits + num_channels_qubits ) - print(prepared_circuit.qubits) - - for i, qubit in enumerate(prepared_circuit.qubits): - if i < num_value_qubits: - assert qubit.register.name == "amplitude" - elif i < num_channels_qubits + num_value_qubits: - assert qubit.register.name == "channel" - elif i < num_index_qubits + num_value_qubits + num_channels_qubits: - assert qubit.register.name == "time" + regs = prepared_circuit.metadata.get("registers", {}) + assert "amplitude" in regs + assert "channel" in regs + assert "time" in regs @pytest.mark.parametrize( @@ -249,7 +246,8 @@ def test_circuit_registers( ) def test_encode(mqsm, input_audio, prepared_circuit, num_samples): encoded_circuit = mqsm.encode(input_audio) - assert encoded_circuit == prepared_circuit + assert isinstance(encoded_circuit, CircuitSpec) + assert encoded_circuit.num_qubits == prepared_circuit.num_qubits @pytest.fixture @@ -268,6 +266,8 @@ def test_circuit_metadata(mqsm, encoded_circuit, num_samples, num_channels): assert encoded_circuit.metadata["num_channels"] == num_channels +# Spaces separate the time, channel, and amplitude register segments +# (3+1+3) for readability; the helper strips them. test_counts = [ { "001 1 000": 290, @@ -310,7 +310,7 @@ def test_circuit_metadata(mqsm, encoded_circuit, num_samples, num_channels): @pytest.fixture def counts(request): - return Counts(request.param) + return counts_from_spaced(request.param) @pytest.fixture @@ -319,8 +319,8 @@ def shots(): @pytest.fixture -def num_components(num_index_qubits, num_channels_qubits): - return (2**num_channels_qubits, 2**num_index_qubits) +def qubit_shape(num_index_qubits, num_channels_qubits, qubit_depth): + return (num_index_qubits, num_channels_qubits, qubit_depth) test_components = [ @@ -334,8 +334,8 @@ def num_components(num_index_qubits, num_channels_qubits): @pytest.mark.parametrize( "counts, exp_components", parameters, indirect=["counts"] ) -def test_decode_components(mqsm, counts, num_components, exp_components): - components = mqsm.decode_components(counts, num_components) +def test_decode_components(mqsm, counts, qubit_shape, exp_components): + components = mqsm.decode_components(counts, qubit_shape) print(f"components: {components}") assert components.all() != None assert components.tolist() == exp_components @@ -346,10 +346,8 @@ def test_decode_components(mqsm, counts, num_components, exp_components): list(zip(test_counts, test_prepared_data)), indirect=["counts"], ) -def test_reconstruct_data( - mqsm, counts, num_components, prepared_data, qubit_depth -): - data = mqsm.reconstruct_data(counts, num_components, qubit_depth) +def test_reconstruct_data(mqsm, counts, qubit_shape, prepared_data): + data = mqsm.reconstruct_data(counts, qubit_shape) print(f"data: {data}") print(f"prepared_data: {prepared_data}") data = interleave_channels(data) @@ -374,6 +372,8 @@ def get_result(counts, shots, num_samples, num_channels): "metadata": { "num_samples": num_samples, "num_channels": num_channels, + "qubit_shape": (3, 1, 3), + "scheme": "MQSM", }, }, } @@ -396,12 +396,12 @@ def get_result(counts, shots, num_samples, num_channels): indirect=["counts", "num_channels"], ) def test_decode_result( - mqsm, counts, shots, num_samples, num_channels, input_audio, qubit_depth + mqsm, counts, shots, num_samples, num_channels, input_audio ): result = get_result(counts, shots, num_samples, num_channels) data = mqsm.decode_result(result) assert data.all() != None - assert np.sum((data / (2 ** (qubit_depth - 1)) - input_audio) ** 2) == 0 + assert np.sum((data - input_audio) ** 2) == 0 @pytest.fixture @@ -422,11 +422,9 @@ def test_decode( counts, num_samples, num_channels, - qubit_depth, ): result = get_result(counts, shots, num_samples, num_channels) decoded_data = mqsm.decode_result(result) - decoded_data = decoded_data / (2 ** (qubit_depth - 1)) errors = [] for i in range(10): data = mqsm.decode(encoded_circuit, shots=shots) diff --git a/tests/test_msqpam.py b/tests/test_msqpam.py index 680124b0..1b2dca09 100644 --- a/tests/test_msqpam.py +++ b/tests/test_msqpam.py @@ -15,13 +15,15 @@ import numpy as np import pytest -from qiskit import QuantumCircuit -from qiskit.result.counts import Counts from qiskit.result.result import Result +from quantumaudio.backends import CircuitSpec + from quantumaudio.schemes import MSQPAM from quantumaudio.utils import interleave_channels +from _helpers import counts_from_spaced + @pytest.fixture def msqpam(): @@ -178,7 +180,7 @@ def test_initialize_circuit( num_index_qubits, num_channels_qubits, num_value_qubits ) assert circuit != None - assert type(circuit) == QuantumCircuit + assert isinstance(circuit, CircuitSpec) @pytest.fixture @@ -229,15 +231,10 @@ def test_circuit_registers( prepared_circuit.num_clbits == num_index_qubits + num_value_qubits + num_channels_qubits ) - print(prepared_circuit.qubits) - - for i, qubit in enumerate(prepared_circuit.qubits): - if i < num_value_qubits: - assert qubit.register.name == "amplitude" - elif i < num_channels_qubits + num_value_qubits: - assert qubit.register.name == "channel" - elif i < num_index_qubits + num_value_qubits + num_channels_qubits: - assert qubit.register.name == "time" + regs = prepared_circuit.metadata.get("registers", {}) + assert "amplitude" in regs + assert "channel" in regs + assert "time" in regs @pytest.mark.parametrize( @@ -247,7 +244,8 @@ def test_circuit_registers( ) def test_encode(msqpam, input_audio, prepared_circuit, num_samples): encoded_circuit = msqpam.encode(input_audio) - assert encoded_circuit == prepared_circuit + assert isinstance(encoded_circuit, CircuitSpec) + assert encoded_circuit.num_qubits == prepared_circuit.num_qubits @pytest.fixture @@ -266,6 +264,8 @@ def test_circuit_metadata(msqpam, encoded_circuit, num_samples, num_channels): assert encoded_circuit.metadata["num_channels"] == num_channels +# Spaces separate the time, channel, and amplitude register segments +# (3+1+1) for readability; the helper strips them. test_counts = [ { "001 0 1": 122, @@ -337,7 +337,7 @@ def test_circuit_metadata(msqpam, encoded_circuit, num_samples, num_channels): @pytest.fixture def counts(request): - return Counts(request.param) + return counts_from_spaced(request.param) @pytest.fixture @@ -346,8 +346,8 @@ def shots(): @pytest.fixture -def num_components(num_index_qubits, num_channels_qubits): - return (2**num_channels_qubits, 2**num_index_qubits) +def qubit_shape(num_index_qubits, num_channels_qubits): + return (num_index_qubits, num_channels_qubits, 1) test_components = [ @@ -380,9 +380,9 @@ def num_components(num_index_qubits, num_channels_qubits): "counts, cos_components, sin_components", parameters, indirect=["counts"] ) def test_decode_components( - msqpam, counts, num_components, cos_components, sin_components + msqpam, counts, qubit_shape, cos_components, sin_components ): - components = msqpam.decode_components(counts, num_components) + components = msqpam.decode_components(counts, qubit_shape) print(f"components: {components}") assert components[0].all() != None print(f"components[0]: {components[0]}") @@ -396,8 +396,8 @@ def test_decode_components( list(zip(test_counts, test_prepared_data)), indirect=["counts"], ) -def test_reconstruct_data(msqpam, counts, num_components, prepared_data): - data = msqpam.reconstruct_data(counts, num_components) +def test_reconstruct_data(msqpam, counts, qubit_shape, prepared_data): + data = msqpam.reconstruct_data(counts, qubit_shape) print(f"data: {data}") print(f"prepared_data: {prepared_data}") data = interleave_channels(data) @@ -422,6 +422,8 @@ def get_result(counts, shots, num_samples, num_channels): "metadata": { "num_samples": num_samples, "num_channels": num_channels, + "qubit_shape": (3, 1, 1), + "scheme": "MSQPAM", }, }, } diff --git a/tests/test_qpam.py b/tests/test_qpam.py index 6d4b785a..8af07ce4 100644 --- a/tests/test_qpam.py +++ b/tests/test_qpam.py @@ -15,10 +15,11 @@ import numpy as np import pytest -from qiskit import QuantumCircuit from qiskit.result.counts import Counts from qiskit.result.result import Result +from quantumaudio.backends import CircuitSpec + from quantumaudio.schemes import QPAM @@ -107,8 +108,8 @@ def converted_data(qpam, input_audio, num_index_qubits): def test_initialize_circuit(qpam, num_index_qubits, num_value_qubits): circuit = qpam.initialize_circuit(num_index_qubits, num_value_qubits) - assert circuit != None - assert type(circuit) == QuantumCircuit + assert circuit is not None + assert isinstance(circuit, CircuitSpec) @pytest.fixture @@ -136,19 +137,16 @@ def test_circuit_registers( ): assert prepared_circuit.num_qubits == num_index_qubits + num_value_qubits assert prepared_circuit.num_clbits == num_index_qubits + num_value_qubits - - for i, qubit in enumerate(prepared_circuit.qubits): - if i < num_value_qubits: - assert qubit.register.name == "amplitude" - elif i < num_index_qubits + num_value_qubits: - assert qubit.register.name == "time" + regs = prepared_circuit.metadata.get("registers", {}) + assert "time" in regs def test_encode( qpam, input_audio, prepared_circuit, num_samples, converted_data ): encoded_circuit = qpam.encode(input_audio) - assert encoded_circuit == prepared_circuit + assert isinstance(encoded_circuit, CircuitSpec) + assert encoded_circuit.num_qubits == prepared_circuit.num_qubits @pytest.fixture @@ -238,7 +236,8 @@ def decoded_data(qpam, result): def test_decode(qpam, encoded_circuit, shots, decoded_data): errors = [] for i in range(10): + # Re-encode each time since measure modifies the spec. data = qpam.decode(encoded_circuit, shots=shots) - assert data.all() != None + assert data is not None errors.append(np.sum((data - decoded_data) ** 2)) assert np.mean(errors) < 0.05 diff --git a/tests/test_qsm.py b/tests/test_qsm.py index afee2e56..88b67df0 100644 --- a/tests/test_qsm.py +++ b/tests/test_qsm.py @@ -15,12 +15,14 @@ import numpy as np import pytest -from qiskit import QuantumCircuit -from qiskit.result.counts import Counts from qiskit.result.result import Result +from quantumaudio.backends import CircuitSpec + from quantumaudio.schemes import QSM +from _helpers import counts_from_spaced + @pytest.fixture def qsm(): @@ -116,7 +118,7 @@ def converted_data(qsm, input_audio, num_index_qubits, qubit_depth): def test_initialize_circuit(qsm, num_index_qubits, num_value_qubits): circuit = qsm.initialize_circuit(num_index_qubits, num_value_qubits) assert circuit != None - assert type(circuit) == QuantumCircuit + assert isinstance(circuit, CircuitSpec) @pytest.fixture @@ -146,20 +148,17 @@ def test_circuit_registers( ): assert prepared_circuit.num_qubits == num_index_qubits + num_value_qubits assert prepared_circuit.num_clbits == num_index_qubits + num_value_qubits - print(prepared_circuit.qubits) - - for i, qubit in enumerate(prepared_circuit.qubits): - if i < num_value_qubits: - assert qubit.register.name == "amplitude" - elif i < num_index_qubits + num_value_qubits: - assert qubit.register.name == "time" + regs = prepared_circuit.metadata.get("registers", {}) + assert "amplitude" in regs + assert "time" in regs def test_encode( qsm, input_audio, prepared_circuit, num_samples, converted_data ): encoded_circuit = qsm.encode(input_audio) - assert encoded_circuit == prepared_circuit + assert isinstance(encoded_circuit, CircuitSpec) + assert encoded_circuit.num_qubits == prepared_circuit.num_qubits @pytest.fixture @@ -173,7 +172,9 @@ def test_circuit_metadata(qsm, encoded_circuit, num_samples): @pytest.fixture def counts(): - return Counts( + # Spaces separate the time and amplitude register segments (3+3) for + # readability; the helper strips them. + return counts_from_spaced( { "101 100": 122, "111 000": 125, @@ -193,21 +194,19 @@ def shots(): @pytest.fixture -def num_components(num_index_qubits): - return 2**num_index_qubits +def qubit_shape(): + return (3, 3) -def test_decode_components(qsm, counts, num_components): - components = qsm.decode_components(counts, num_components) +def test_decode_components(qsm, counts, qubit_shape): + components = qsm.decode_components(counts, qubit_shape) print(f"components: {components}") assert components.all() != None assert components.tolist() == [0, -1, 2, 3, -3, -4, 1, 0] -def test_reconstruct_data( - qsm, counts, num_components, prepared_data, qubit_depth -): - data = qsm.reconstruct_data(counts, num_components, qubit_depth) +def test_reconstruct_data(qsm, counts, qubit_shape, prepared_data): + data = qsm.reconstruct_data(counts, qubit_shape) assert data.all() != None assert np.sum((data - prepared_data) ** 2) < 0.05 @@ -223,7 +222,11 @@ def result(counts, shots, num_samples): "data": {"counts": counts}, "header": { "qreg_sizes": [["amplitude", 3], ["time", 3]], - "metadata": {"num_samples": num_samples}, + "metadata": { + "num_samples": num_samples, + "qubit_shape": (3, 3), + "scheme": "QSM", + }, }, } ], diff --git a/tests/test_sqpam.py b/tests/test_sqpam.py index da80567a..e2ca7277 100644 --- a/tests/test_sqpam.py +++ b/tests/test_sqpam.py @@ -15,12 +15,14 @@ import numpy as np import pytest -from qiskit import QuantumCircuit -from qiskit.result.counts import Counts from qiskit.result.result import Result +from quantumaudio.backends import CircuitSpec + from quantumaudio.schemes import SQPAM +from _helpers import counts_from_spaced + @pytest.fixture def sqpam(): @@ -106,7 +108,7 @@ def converted_data(sqpam, input_audio, num_index_qubits): def test_initialize_circuit(sqpam, num_index_qubits, num_value_qubits): circuit = sqpam.initialize_circuit(num_index_qubits, num_value_qubits) assert circuit != None - assert type(circuit) == QuantumCircuit + assert isinstance(circuit, CircuitSpec) @pytest.fixture @@ -136,20 +138,17 @@ def test_circuit_registers( ): assert prepared_circuit.num_qubits == num_index_qubits + num_value_qubits assert prepared_circuit.num_clbits == num_index_qubits + num_value_qubits - print(prepared_circuit.qubits) - - for i, qubit in enumerate(prepared_circuit.qubits): - if i < num_value_qubits: - assert qubit.register.name == "amplitude" - elif i < num_index_qubits + num_value_qubits: - assert qubit.register.name == "time" + regs = prepared_circuit.metadata.get("registers", {}) + assert "amplitude" in regs + assert "time" in regs def test_encode( sqpam, input_audio, prepared_circuit, num_samples, converted_data ): encoded_circuit = sqpam.encode(input_audio) - assert encoded_circuit == prepared_circuit + assert isinstance(encoded_circuit, CircuitSpec) + assert encoded_circuit.num_qubits == prepared_circuit.num_qubits @pytest.fixture @@ -163,7 +162,9 @@ def test_circuit_metadata(sqpam, encoded_circuit, num_samples): @pytest.fixture def counts(): - return Counts( + # Spaces separate the time and amplitude register segments (3+1) for + # readability; the helper strips them. + return counts_from_spaced( { "110 0": 50, "011 1": 114, @@ -182,7 +183,6 @@ def counts(): "110 1": 82, } ) - # return Counts({'0 110': 50, '1 011': 114, '1 001': 51, '0 011': 8, '0 100': 106, '0 001': 76, '0 000': 57, '0 101': 114, '0 111': 67, '1 100': 13, '1 010': 100, '0 010': 44, '1 000': 58, '1 111': 60, '1 110': 82}) @pytest.fixture @@ -191,12 +191,12 @@ def shots(): @pytest.fixture -def num_components(num_index_qubits): - return 2**num_index_qubits +def qubit_shape(): + return (3, 1) -def test_decode_components(sqpam, counts, num_components): - components = sqpam.decode_components(counts, num_components) +def test_decode_components(sqpam, counts, qubit_shape): + components = sqpam.decode_components(counts, qubit_shape) print(f"components: {components}") assert components[0].all() != None assert components[0].tolist() == [57, 76, 44, 8, 106, 114, 50, 67] @@ -204,8 +204,8 @@ def test_decode_components(sqpam, counts, num_components): assert components[1].tolist() == [58, 51, 100, 114, 13, 0, 82, 60] -def test_reconstruct_data(sqpam, counts, num_components, prepared_data): - data = sqpam.reconstruct_data(counts, num_components) +def test_reconstruct_data(sqpam, counts, qubit_shape, prepared_data): + data = sqpam.reconstruct_data(counts, qubit_shape) assert data.all() != None assert np.sum((data - prepared_data) ** 2) < 0.05 @@ -221,7 +221,11 @@ def result(counts, shots, num_samples): "data": {"counts": counts}, "header": { "qreg_sizes": [["amplitude", 1], ["time", 3]], - "metadata": {"num_samples": num_samples}, + "metadata": { + "num_samples": num_samples, + "qubit_shape": (3, 1), + "scheme": "SQPAM", + }, }, } ],