diff --git a/quantumaudio/backends/__init__.py b/quantumaudio/backends/__init__.py new file mode 100644 index 00000000..97316c53 --- /dev/null +++ b/quantumaudio/backends/__init__.py @@ -0,0 +1,42 @@ +# 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, +) + +__all__ = [ + "Backend", + "CircuitSpec", + "GateOp", + "GateType", + "UnifiedResult", + "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/schemes/mqsm.py b/quantumaudio/schemes/mqsm.py index ac43ef0c..72d0921e 100644 --- a/quantumaudio/schemes/mqsm.py +++ b/quantumaudio/schemes/mqsm.py @@ -13,7 +13,7 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np import qiskit @@ -360,7 +360,7 @@ def decode_counts( self, counts: Union[dict, qiskit.result.Counts], 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 conversion did at encoding stage. @@ -411,7 +411,7 @@ def decode_result( self, result: qiskit.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. @@ -441,10 +441,8 @@ def decode( self, circuit: qiskit.QuantumCircuit, 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), + execute_function: utils.ExecuteFunction = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns the Original Audio Array. diff --git a/quantumaudio/schemes/msqpam.py b/quantumaudio/schemes/msqpam.py index 821d7be3..196c7bb9 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -13,7 +13,7 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np import qiskit @@ -370,7 +370,7 @@ def decode_counts( counts: Union[dict, qiskit.result.Counts], 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 conversion did at encoding stage. @@ -420,7 +420,7 @@ def decode_result( result: qiskit.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. @@ -455,10 +455,8 @@ def decode( circuit: qiskit.QuantumCircuit, 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), + execute_function: utils.ExecuteFunction = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns the Original Audio Array. @@ -479,7 +477,7 @@ def decode( Array of decoded values """ self.measure(circuit) - result = utils.execute(circuit=circuit, **kwargs) + result = execute_function(circuit=circuit, **kwargs) data = self.decode_result( result=result, metadata=metadata, diff --git a/quantumaudio/schemes/qpam.py b/quantumaudio/schemes/qpam.py index 98122eaf..ca31bf06 100644 --- a/quantumaudio/schemes/qpam.py +++ b/quantumaudio/schemes/qpam.py @@ -13,7 +13,7 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np import qiskit @@ -345,9 +345,7 @@ def decode( shots: Optional[int] = 8000, norm: Optional[float] = None, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + execute_function: utils.ExecuteFunction = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns back the Original Audio Array. diff --git a/quantumaudio/schemes/qsm.py b/quantumaudio/schemes/qsm.py index ca0b037a..23dd259a 100644 --- a/quantumaudio/schemes/qsm.py +++ b/quantumaudio/schemes/qsm.py @@ -13,7 +13,7 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np import qiskit @@ -361,9 +361,7 @@ def decode( circuit: qiskit.QuantumCircuit, metadata: Optional[dict] = None, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + execute_function: utils.ExecuteFunction = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns back the Original Audio Array. diff --git a/quantumaudio/schemes/sqpam.py b/quantumaudio/schemes/sqpam.py index e127f989..e2de8d42 100644 --- a/quantumaudio/schemes/sqpam.py +++ b/quantumaudio/schemes/sqpam.py @@ -13,7 +13,7 @@ # limitations under the License. # ========================================================================== -from typing import Optional, Union, Callable, Any, Tuple +from typing import Optional, Union, Tuple import numpy as np import qiskit @@ -378,9 +378,7 @@ def decode( metadata: Optional[dict] = None, inverted: bool = False, keep_padding: bool = False, - execute_function: Callable[ - [qiskit.QuantumCircuit, dict], Any - ] = utils.execute, + execute_function: utils.ExecuteFunction = utils.execute, **kwargs, ) -> np.ndarray: """Given a qiskit circuit, decodes and returns back the Original Audio Array. 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..f23e71ea 100644 --- a/quantumaudio/utils/execute.py +++ b/quantumaudio/utils/execute.py @@ -15,9 +15,27 @@ import qiskit_aer from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager -from typing import Type, Any +from typing import Type, Any, Protocol import importlib +import qiskit + + +class ExecuteFunction(Protocol): + """Callable protocol for circuit execution backends used by scheme decoders. + + An execute function takes a ``circuit`` argument and may accept + any number of additional keyword arguments (e.g. ``shots``, ``backend``). + It returns a result object compatible with + :func:`quantumaudio.utils.results.get_counts` and + :func:`quantumaudio.utils.results.get_metadata`. + """ + + def __call__( + self, circuit: qiskit.QuantumCircuit, **kwargs: Any + ) -> Any: ... + + # Optional Import if exists _Sampler = ( getattr(importlib.import_module("qiskit_ibm_runtime"), "SamplerV2", None) @@ -37,6 +55,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 +66,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 +96,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 +106,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/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 00000000..dd0e73c8 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,353 @@ +# 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, + is_available, + 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" 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_msqpam.py b/tests/test_msqpam.py index 680124b0..9d48d05b 100644 --- a/tests/test_msqpam.py +++ b/tests/test_msqpam.py @@ -476,3 +476,40 @@ def test_decode( print(f"errors: {errors}") assert np.mean(errors) < 0.1 + + +def test_decode_uses_execute_function(msqpam, monkeypatch): + # Regression test: `MSQPAM.decode` must invoke the supplied + # `execute_function` (rather than the default `utils.execute`) and forward + # `**kwargs` to it. The stub's returned `Result` must be the one passed on + # to `decode_result`. + canned_result = object() + sentinel = object() + execute_calls = [] + decode_result_calls = [] + + def stub_execute(circuit, **kwargs): + execute_calls.append((circuit, kwargs)) + return canned_result + + def stub_decode_result(result, **kwargs): + decode_result_calls.append(result) + return sentinel + + monkeypatch.setattr(msqpam, "decode_result", stub_decode_result) + + encoded_circuit = msqpam.encode(test_inputs[0]) + data = msqpam.decode( + encoded_circuit, + execute_function=stub_execute, + shots=1234, + extra_kwarg="forwarded", + ) + + assert len(execute_calls) == 1 + received_circuit, received_kwargs = execute_calls[0] + assert received_circuit is encoded_circuit + assert received_kwargs == {"shots": 1234, "extra_kwarg": "forwarded"} + + assert decode_result_calls == [canned_result] + assert data is sentinel