From e8f791103a25ce6dcfb4e4a78bf454a9c6938573 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Thu, 16 Apr 2026 20:36:46 +0200 Subject: [PATCH 01/19] fix(schemes): honour `execute_function` in `MSQPAM.decode` --- quantumaudio/schemes/msqpam.py | 2 +- tests/test_msqpam.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/quantumaudio/schemes/msqpam.py b/quantumaudio/schemes/msqpam.py index 821d7be3..a3bb7787 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -479,7 +479,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/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 From 0d6fb1bce8e33414142f1850b35023b93dcf2238 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Thu, 16 Apr 2026 20:40:01 +0200 Subject: [PATCH 02/19] refactor(schemes): type `execute_function` with `ExecuteFunction` protocol --- quantumaudio/schemes/mqsm.py | 12 +++++------- quantumaudio/schemes/msqpam.py | 12 +++++------- quantumaudio/schemes/qpam.py | 6 ++---- quantumaudio/schemes/qsm.py | 6 ++---- quantumaudio/schemes/sqpam.py | 6 ++---- quantumaudio/utils/execute.py | 28 +++++++++++++++++++++++++++- 6 files changed, 43 insertions(+), 27 deletions(-) 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 a3bb7787..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. 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/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. From e749f59f33c46784d828e9fd8a2b406c9f191d41 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Tue, 28 Apr 2026 21:43:23 +0200 Subject: [PATCH 03/19] fix(utils): saturate quantize output to avoid sign-flip wraparound --- quantumaudio/utils/convert.py | 32 ++++++++++++++-- tests/test_convert.py | 72 +++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/test_convert.py 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/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 From d3a4b9088d8b8980b0dcf0159bcf11ac053e2435 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 2 May 2026 09:56:49 +0200 Subject: [PATCH 04/19] feat(backends): add core types and abstractions Introduce a framework-agnostic backend abstraction layer: - `GateType` enum covering all gates used by quantumaudio schemes - `GateOp` frozen dataclass for a single gate operation - `CircuitSpec` with fluent builder API and Mottonen state-prep decomposition for non-negative real state vectors into RY + CX gates - `UnifiedResult` normalising execution output across backends, with `to_qiskit_result()` bridge for backward compatibility - Backend `ABC` and `BackendRegistry` singleton for provider discovery - `is_available()` and `require()` helpers for optional dependencies - Tests covering the above --- quantumaudio/backends/__init__.py | 42 +++ quantumaudio/backends/_optional.py | 56 ++++ quantumaudio/backends/core/__init__.py | 33 +++ quantumaudio/backends/core/backend.py | 86 ++++++ quantumaudio/backends/core/circuit.py | 308 +++++++++++++++++++++ quantumaudio/backends/core/result.py | 123 +++++++++ quantumaudio/backends/core/types.py | 55 ++++ tests/test_backends.py | 353 +++++++++++++++++++++++++ 8 files changed, 1056 insertions(+) create mode 100644 quantumaudio/backends/__init__.py create mode 100644 quantumaudio/backends/_optional.py create mode 100644 quantumaudio/backends/core/__init__.py create mode 100644 quantumaudio/backends/core/backend.py create mode 100644 quantumaudio/backends/core/circuit.py create mode 100644 quantumaudio/backends/core/result.py create mode 100644 quantumaudio/backends/core/types.py create mode 100644 tests/test_backends.py 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/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" From f891d402a56aaa444bc6406723002b2a1ba82d6c Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 19 Apr 2026 08:58:36 +0200 Subject: [PATCH 05/19] feat(backends): add Qiskit backend provider Implement the `Backend` interface for Qiskit/AerSimulator, translating `CircuitSpec` to `QuantumCircuit` with named register reconstruction from metadata. Wire up the public API with `get_backend()` and `available_backends()` functions, and auto-register the Qiskit provider on import. --- quantumaudio/backends/__init__.py | 16 ++ quantumaudio/backends/providers/__init__.py | 26 +++ .../backends/providers/qiskit_backend.py | 184 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 quantumaudio/backends/providers/__init__.py create mode 100644 quantumaudio/backends/providers/qiskit_backend.py diff --git a/quantumaudio/backends/__init__.py b/quantumaudio/backends/__init__.py index 97316c53..f9f76b52 100644 --- a/quantumaudio/backends/__init__.py +++ b/quantumaudio/backends/__init__.py @@ -30,12 +30,28 @@ 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/providers/__init__.py b/quantumaudio/backends/providers/__init__.py new file mode 100644 index 00000000..fb30b26c --- /dev/null +++ b/quantumaudio/backends/providers/__init__.py @@ -0,0 +1,26 @@ +# 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) 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) From afca30d08a0362b4d1563fd1414ef13b3d883495 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 19 Apr 2026 08:58:50 +0200 Subject: [PATCH 06/19] feat(backends): add Cirq backend provider Implement the `Backend` interface for Google Cirq with automatic registration when `cirq` is installed. Cirq is added as an optional dependency under the `[cirq]` extras group. --- pyproject.toml | 1 + quantumaudio/backends/providers/__init__.py | 7 + .../backends/providers/cirq_backend.py | 174 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 quantumaudio/backends/providers/cirq_backend.py diff --git a/pyproject.toml b/pyproject.toml index 2b9aae41..176b1928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ Repository = "https://github.com/moth-quantum/quantum-audio" Documentation = "https://quantumaudio.readthedocs.io/" [project.optional-dependencies] +cirq = ["cirq-core"] demos = [ "soundfile==0.12.1", "librosa==0.10.2.post1", diff --git a/quantumaudio/backends/providers/__init__.py b/quantumaudio/backends/providers/__init__.py index fb30b26c..a74e5ef2 100644 --- a/quantumaudio/backends/providers/__init__.py +++ b/quantumaudio/backends/providers/__init__.py @@ -24,3 +24,10 @@ ) registry.register("qiskit", QiskitBackend) + +if is_available("cirq"): + from quantumaudio.backends.providers.cirq_backend import ( + CirqBackend, + ) + + registry.register("cirq", CirqBackend) 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 From bb6abb54496ecb5a0bd2af222e6f8d66f0a9ae5b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Wed, 6 May 2026 22:01:33 +0200 Subject: [PATCH 07/19] feat(quantumaudio): export backends subpackage --- quantumaudio/__init__.py | 1 + 1 file changed, 1 insertion(+) 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", From db837bff848cd2dc37b846c3f5427ad95688ecd4 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 19 Apr 2026 08:59:20 +0200 Subject: [PATCH 08/19] refactor(utils): support `CircuitSpec` and `UnifiedResult` alongside Qiskit types Add `CircuitSpec` and `UnifiedResult` branches to `get_counts()`, `get_metadata()`, and `pick_key_from_instance()` in `results.py`. Update `draw_circuit()` in preview.py to convert `CircuitSpec` to `QuantumCircuit` for visualisation. All existing Qiskit-only code paths are preserved. --- quantumaudio/utils/preview.py | 13 +++++++--- quantumaudio/utils/results.py | 46 +++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 19 deletions(-) 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 From 8c7941e1692344be2eb5b160b7ff1852b498c7d4 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 26 Apr 2026 10:06:14 +0200 Subject: [PATCH 09/19] test(backends): add tests for `CircuitSpec`, providers, and cross-backend schemes --- tests/test_backends.py | 232 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/tests/test_backends.py b/tests/test_backends.py index dd0e73c8..3b6eb76e 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -25,7 +25,10 @@ GateOp, GateType, UnifiedResult, + available_backends, + get_backend, is_available, + registry, require, ) @@ -351,3 +354,232 @@ def test_require_does_not_swallow_nested_import_error(tmp_path, monkeypatch): # 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 + + +# ====================================================================== +# Cross-backend scheme round-trips +# ====================================================================== + + +BACKENDS = ["qiskit"] +if is_available("cirq"): + BACKENDS.append("cirq") + + +@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 From 57471812817905681227529a69541aa66b5f8e47 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 19 Apr 2026 09:06:20 +0200 Subject: [PATCH 10/19] test(schemes): update existing tests for `CircuitSpec` types --- tests/_helpers.py | 31 +++++++++++++++++++++++++++ tests/test_mqsm.py | 50 +++++++++++++++++++++----------------------- tests/test_msqpam.py | 42 +++++++++++++++++++------------------ tests/test_qpam.py | 21 +++++++++---------- tests/test_qsm.py | 45 ++++++++++++++++++++------------------- tests/test_sqpam.py | 44 ++++++++++++++++++++------------------ 6 files changed, 135 insertions(+), 98 deletions(-) create mode 100644 tests/_helpers.py 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_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 9d48d05b..9094cae5 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", + }, }, } ], From 03f45b95017b98ddb1e9971f87dfc0d7b6ddd518 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 26 Apr 2026 11:15:10 +0200 Subject: [PATCH 11/19] refactor(schemes)!: emit `CircuitSpec`, retain `execute_function` for compat BREAKING CHANGE: `encode()` now returns `CircuitSpec` instead of `QuantumCircuit`. --- quantumaudio/schemes/base_scheme.py | 11 +- quantumaudio/schemes/mqsm.py | 184 ++++++++++++++------------ quantumaudio/schemes/msqpam.py | 197 ++++++++++++++-------------- quantumaudio/schemes/qpam.py | 142 +++++++++++--------- quantumaudio/schemes/qsm.py | 148 ++++++++++++--------- quantumaudio/schemes/sqpam.py | 172 ++++++++++++------------ quantumaudio/utils/circuit.py | 47 ++++--- 7 files changed, 488 insertions(+), 413 deletions(-) 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 72d0921e..4e8724a4 100644 --- a/quantumaudio/schemes/mqsm.py +++ b/quantumaudio/schemes/mqsm.py @@ -16,10 +16,10 @@ 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 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[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[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,28 +433,48 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, keep_padding: Tuple[bool, bool] = (False, False), - execute_function: utils.ExecuteFunction = utils.execute, + execute_function: Optional[utils.ExecuteFunction] = None, + 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`. + execute_function: Legacy function to execute the circuit for decoding. + 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 - ) - return data + if execute_function is not None: + # Legacy path: convert CircuitSpec to Qiskit circuit. + from quantumaudio.backends import get_backend + + be = get_backend("qiskit") + native = be.build_circuit(circuit) + result = execute_function(circuit=native, **kwargs) + return self.decode_result( + result=result, + metadata=metadata, + keep_padding=keep_padding, + ) + else: + # New path: use backend. + from quantumaudio.backends import get_backend + + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + return self.decode_counts( + counts=result.counts, + metadata=metadata, + keep_padding=keep_padding, + ) diff --git a/quantumaudio/schemes/msqpam.py b/quantumaudio/schemes/msqpam.py index 196c7bb9..fe8475c6 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -16,9 +16,9 @@ from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec 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 - ) - - # attach sub-circuit - circuit.append( - sub_circuit, list(i for i in range(circuit.num_qubits - 1, -1, -1)) + 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) - 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[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[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,55 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, inverted: bool = False, keep_padding: Tuple[bool, bool] = (False, False), - execute_function: utils.ExecuteFunction = utils.execute, + execute_function: Optional[utils.ExecuteFunction] = None, + 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`. + execute_function: Legacy function to execute the circuit for decoding. + 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, - inverted=inverted, - keep_padding=keep_padding, - ) - return data + if execute_function is not None: + # Legacy path: convert CircuitSpec to Qiskit circuit. + from quantumaudio.backends import get_backend + + be = get_backend("qiskit") + native = be.build_circuit(circuit) + result = execute_function(circuit=native, **kwargs) + return self.decode_result( + result=result, + metadata=metadata, + inverted=inverted, + keep_padding=keep_padding, + ) + else: + # New path: use backend. + from quantumaudio.backends import get_backend + + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + return 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 ca31bf06..872d5f05 100644 --- a/quantumaudio/schemes/qpam.py +++ b/quantumaudio/schemes/qpam.py @@ -16,9 +16,9 @@ from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec 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,39 +345,58 @@ 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: utils.ExecuteFunction = utils.execute, + execute_function: Optional[utils.ExecuteFunction] = None, + 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**) + execute_function: Legacy function to execute the circuit for decoding. + 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, - metadata=metadata, - shots=shots, - norm=norm, - keep_padding=keep_padding, - ) - return data + if execute_function is not None: + # Legacy path: convert CircuitSpec to Qiskit circuit. + from quantumaudio.backends import get_backend + + be = get_backend("qiskit") + native = be.build_circuit(circuit) + kwargs["shots"] = shots + result = execute_function(circuit=native, **kwargs) + return self.decode_result( + result=result, + metadata=metadata, + shots=shots, + norm=norm, + keep_padding=keep_padding, + ) + else: + # New path: use backend. + from quantumaudio.backends import get_backend + + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=shots) + if metadata is None: + metadata = circuit.metadata + metadata["shots"] = shots + return self.decode_counts( + counts=result.counts, + metadata=metadata, + shots=shots, + norm=norm, + keep_padding=keep_padding, + ) diff --git a/quantumaudio/schemes/qsm.py b/quantumaudio/schemes/qsm.py index 23dd259a..b14e64fc 100644 --- a/quantumaudio/schemes/qsm.py +++ b/quantumaudio/schemes/qsm.py @@ -16,10 +16,10 @@ 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 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,28 +356,48 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, keep_padding: bool = False, - execute_function: utils.ExecuteFunction = utils.execute, + execute_function: Optional[utils.ExecuteFunction] = None, + 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`. + execute_function: Legacy function to execute the circuit for decoding. + 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 - ) - return data + if execute_function is not None: + # Legacy path: convert CircuitSpec to Qiskit circuit. + from quantumaudio.backends import get_backend + + be = get_backend("qiskit") + native = be.build_circuit(circuit) + result = execute_function(circuit=native, **kwargs) + return self.decode_result( + result=result, + metadata=metadata, + keep_padding=keep_padding, + ) + else: + # New path: use backend. + from quantumaudio.backends import get_backend + + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + return self.decode_counts( + counts=result.counts, + metadata=metadata, + keep_padding=keep_padding, + ) diff --git a/quantumaudio/schemes/sqpam.py b/quantumaudio/schemes/sqpam.py index e2de8d42..e3c129e8 100644 --- a/quantumaudio/schemes/sqpam.py +++ b/quantumaudio/schemes/sqpam.py @@ -16,9 +16,9 @@ from typing import Optional, Union, Tuple import numpy as np -import qiskit from quantumaudio import utils +from quantumaudio.backends import CircuitSpec 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] - ) - # 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. 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,52 @@ def decode_result( def decode( self, - circuit: qiskit.QuantumCircuit, + circuit: CircuitSpec, metadata: Optional[dict] = None, inverted: bool = False, keep_padding: bool = False, - execute_function: utils.ExecuteFunction = utils.execute, + execute_function: Optional[utils.ExecuteFunction] = None, + 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`. + execute_function: Legacy function to execute the circuit for decoding. + 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, - inverted=inverted, - keep_padding=keep_padding, - ) - return data + if execute_function is not None: + # Legacy path: convert CircuitSpec to Qiskit circuit. + from quantumaudio.backends import get_backend + + be = get_backend("qiskit") + native = be.build_circuit(circuit) + result = execute_function(circuit=native, **kwargs) + return self.decode_result( + result=result, + metadata=metadata, + inverted=inverted, + keep_padding=keep_padding, + ) + else: + # New path: use backend. + from quantumaudio.backends import get_backend + + be = get_backend(backend or "qiskit") + result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) + if metadata is None: + metadata = circuit.metadata + return 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 From 6e7379cce5e61e25c824b82ddf61cdbf5333c57b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 26 Apr 2026 13:10:20 +0200 Subject: [PATCH 12/19] refactor(schemes)!: remove `execute_function` parameter from `decode` BREAKING CHANGE: The `execute_function` parameter is removed from all scheme `decode()` methods, as it is redundant with the backend system. The `ExecuteFunction` protocol is removed from `quantumaudio.utils.execute`. The backend parameter and the lower-level `decode_counts()`/`decode_result()` methods cover all use cases. --- README.md | 10 ++----- quantumaudio/schemes/mqsm.py | 39 ++++++++------------------- quantumaudio/schemes/msqpam.py | 42 +++++++++-------------------- quantumaudio/schemes/qpam.py | 48 ++++++++++------------------------ quantumaudio/schemes/qsm.py | 39 ++++++++------------------- quantumaudio/schemes/sqpam.py | 42 +++++++++-------------------- quantumaudio/utils/execute.py | 20 +------------- tests/test_msqpam.py | 37 -------------------------- 8 files changed, 63 insertions(+), 214 deletions(-) diff --git a/README.md b/README.md index bc46c6ee..51598c44 100644 --- a/README.md +++ b/README.md @@ -220,19 +220,13 @@ 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`.
- + ## 📘 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/quantumaudio/schemes/mqsm.py b/quantumaudio/schemes/mqsm.py index 4e8724a4..9bb7f42d 100644 --- a/quantumaudio/schemes/mqsm.py +++ b/quantumaudio/schemes/mqsm.py @@ -19,7 +19,7 @@ from bitstring import BitArray from quantumaudio import utils -from quantumaudio.backends import CircuitSpec +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -436,7 +436,6 @@ def decode( circuit: CircuitSpec, metadata: Optional[dict] = None, keep_padding: Tuple[bool, bool] = (False, False), - execute_function: Optional[utils.ExecuteFunction] = None, backend=None, **kwargs, ) -> np.ndarray: @@ -446,35 +445,19 @@ def decode( 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: Legacy function to execute the circuit for decoding. backend: Backend name to execute on (default: "qiskit"). Return: Array of decoded values. """ self.measure(circuit) - if execute_function is not None: - # Legacy path: convert CircuitSpec to Qiskit circuit. - from quantumaudio.backends import get_backend - - be = get_backend("qiskit") - native = be.build_circuit(circuit) - result = execute_function(circuit=native, **kwargs) - return self.decode_result( - result=result, - metadata=metadata, - keep_padding=keep_padding, - ) - else: - # New path: use backend. - from quantumaudio.backends import get_backend - - be = get_backend(backend or "qiskit") - result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) - if metadata is None: - metadata = circuit.metadata - return self.decode_counts( - counts=result.counts, - 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 fe8475c6..3fd7d863 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -18,7 +18,7 @@ import numpy as np from quantumaudio import utils -from quantumaudio.backends import CircuitSpec +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -442,7 +442,6 @@ def decode( metadata: Optional[dict] = None, inverted: bool = False, keep_padding: Tuple[bool, bool] = (False, False), - execute_function: Optional[utils.ExecuteFunction] = None, backend=None, **kwargs, ) -> np.ndarray: @@ -456,37 +455,20 @@ def decode( - Dimension 0 for Channels. - Dimension 1 for Time. - execute_function: Legacy function to execute the circuit for decoding. backend: Backend name to execute on (default: "qiskit"). Return: Array of decoded values. """ self.measure(circuit) - if execute_function is not None: - # Legacy path: convert CircuitSpec to Qiskit circuit. - from quantumaudio.backends import get_backend - - be = get_backend("qiskit") - native = be.build_circuit(circuit) - result = execute_function(circuit=native, **kwargs) - return self.decode_result( - result=result, - metadata=metadata, - inverted=inverted, - keep_padding=keep_padding, - ) - else: - # New path: use backend. - from quantumaudio.backends import get_backend - - be = get_backend(backend or "qiskit") - result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) - if metadata is None: - metadata = circuit.metadata - return self.decode_counts( - counts=result.counts, - metadata=metadata, - inverted=inverted, - 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, + inverted=inverted, + keep_padding=keep_padding, + ) + return data diff --git a/quantumaudio/schemes/qpam.py b/quantumaudio/schemes/qpam.py index 872d5f05..93c962e4 100644 --- a/quantumaudio/schemes/qpam.py +++ b/quantumaudio/schemes/qpam.py @@ -18,7 +18,7 @@ import numpy as np from quantumaudio import utils -from quantumaudio.backends import CircuitSpec +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -350,7 +350,6 @@ def decode( shots: Optional[int] = 8000, norm: Optional[float] = None, keep_padding: bool = False, - execute_function: Optional[utils.ExecuteFunction] = None, backend=None, **kwargs, ) -> np.ndarray: @@ -362,41 +361,22 @@ def decode( 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: Legacy function to execute the circuit for decoding. backend: Backend name to execute on (default: "qiskit"). Return: Array of decoded values. """ self.measure(circuit) - if execute_function is not None: - # Legacy path: convert CircuitSpec to Qiskit circuit. - from quantumaudio.backends import get_backend - - be = get_backend("qiskit") - native = be.build_circuit(circuit) - kwargs["shots"] = shots - result = execute_function(circuit=native, **kwargs) - return self.decode_result( - result=result, - metadata=metadata, - shots=shots, - norm=norm, - keep_padding=keep_padding, - ) - else: - # New path: use backend. - from quantumaudio.backends import get_backend - - be = get_backend(backend or "qiskit") - result = be.run_spec(circuit, shots=shots) - if metadata is None: - metadata = circuit.metadata - metadata["shots"] = shots - return self.decode_counts( - counts=result.counts, - metadata=metadata, - shots=shots, - norm=norm, - keep_padding=keep_padding, - ) + 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, + keep_padding=keep_padding, + ) + return data diff --git a/quantumaudio/schemes/qsm.py b/quantumaudio/schemes/qsm.py index b14e64fc..7a6a5ff9 100644 --- a/quantumaudio/schemes/qsm.py +++ b/quantumaudio/schemes/qsm.py @@ -19,7 +19,7 @@ from bitstring import BitArray from quantumaudio import utils -from quantumaudio.backends import CircuitSpec +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -359,7 +359,6 @@ def decode( circuit: CircuitSpec, metadata: Optional[dict] = None, keep_padding: bool = False, - execute_function: Optional[utils.ExecuteFunction] = None, backend=None, **kwargs, ) -> np.ndarray: @@ -369,35 +368,19 @@ def decode( 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: Legacy function to execute the circuit for decoding. backend: Backend name to execute on (default: "qiskit"). Return: Array of decoded values. """ self.measure(circuit) - if execute_function is not None: - # Legacy path: convert CircuitSpec to Qiskit circuit. - from quantumaudio.backends import get_backend - - be = get_backend("qiskit") - native = be.build_circuit(circuit) - result = execute_function(circuit=native, **kwargs) - return self.decode_result( - result=result, - metadata=metadata, - keep_padding=keep_padding, - ) - else: - # New path: use backend. - from quantumaudio.backends import get_backend - - be = get_backend(backend or "qiskit") - result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) - if metadata is None: - metadata = circuit.metadata - return self.decode_counts( - counts=result.counts, - 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 e3c129e8..7b18a708 100644 --- a/quantumaudio/schemes/sqpam.py +++ b/quantumaudio/schemes/sqpam.py @@ -18,7 +18,7 @@ import numpy as np from quantumaudio import utils -from quantumaudio.backends import CircuitSpec +from quantumaudio.backends import CircuitSpec, get_backend from .base_scheme import Scheme @@ -363,7 +363,6 @@ def decode( metadata: Optional[dict] = None, inverted: bool = False, keep_padding: bool = False, - execute_function: Optional[utils.ExecuteFunction] = None, backend=None, **kwargs, ) -> np.ndarray: @@ -374,37 +373,20 @@ def decode( 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: Legacy function to execute the circuit for decoding. backend: Backend name to execute on (default: "qiskit"). Return: Array of decoded values. """ self.measure(circuit) - if execute_function is not None: - # Legacy path: convert CircuitSpec to Qiskit circuit. - from quantumaudio.backends import get_backend - - be = get_backend("qiskit") - native = be.build_circuit(circuit) - result = execute_function(circuit=native, **kwargs) - return self.decode_result( - result=result, - metadata=metadata, - inverted=inverted, - keep_padding=keep_padding, - ) - else: - # New path: use backend. - from quantumaudio.backends import get_backend - - be = get_backend(backend or "qiskit") - result = be.run_spec(circuit, shots=kwargs.get("shots", 8000)) - if metadata is None: - metadata = circuit.metadata - return self.decode_counts( - counts=result.counts, - metadata=metadata, - inverted=inverted, - 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, + inverted=inverted, + keep_padding=keep_padding, + ) + return data diff --git a/quantumaudio/utils/execute.py b/quantumaudio/utils/execute.py index f23e71ea..3de236dc 100644 --- a/quantumaudio/utils/execute.py +++ b/quantumaudio/utils/execute.py @@ -15,27 +15,9 @@ import qiskit_aer from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager -from typing import Type, Any, Protocol +from typing import Type, Any 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) diff --git a/tests/test_msqpam.py b/tests/test_msqpam.py index 9094cae5..1b2dca09 100644 --- a/tests/test_msqpam.py +++ b/tests/test_msqpam.py @@ -478,40 +478,3 @@ 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 From 882e814498584688a1c558183770c417b987e319 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Tue, 28 Apr 2026 13:23:12 +0200 Subject: [PATCH 13/19] feat(backends): add PennyLane backend provider --- README.md | 13 ++ pyproject.toml | 1 + quantumaudio/backends/providers/__init__.py | 7 + .../backends/providers/pennylane_backend.py | 189 ++++++++++++++++++ tests/test_backends.py | 48 +++++ 5 files changed, 258 insertions(+) create mode 100644 quantumaudio/backends/providers/pennylane_backend.py diff --git a/README.md b/README.md index 51598c44..fa9af615 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,19 @@ A Scheme's ```decode()``` method uses local [_AerSimulator_](https://github.com/ ### Running on External Quantum Backends +For PennyLane-supported hardware (IonQ, Amazon Braket, Rigetti, Quantinuum, IQM, Pasqal, ...), construct `PennyLaneBackend` with the device name, then drive it through `build_circuit` / `run` / `decode_counts`: + +```python +from quantumaudio.backends.providers.pennylane_backend import PennyLaneBackend + +be = PennyLaneBackend(device="ionq.simulator") # or "ionq.qpu" +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; install the corresponding plugin (`pennylane-ionq`, `amazon-braket-pennylane-plugin`, ...) for your target. + 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: - **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), diff --git a/pyproject.toml b/pyproject.toml index 176b1928..3b7bc42b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ Documentation = "https://quantumaudio.readthedocs.io/" [project.optional-dependencies] cirq = ["cirq-core"] +pennylane = ["pennylane"] demos = [ "soundfile==0.12.1", "librosa==0.10.2.post1", diff --git a/quantumaudio/backends/providers/__init__.py b/quantumaudio/backends/providers/__init__.py index a74e5ef2..b7eb0884 100644 --- a/quantumaudio/backends/providers/__init__.py +++ b/quantumaudio/backends/providers/__init__.py @@ -31,3 +31,10 @@ ) registry.register("cirq", CirqBackend) + +if is_available("pennylane"): + from quantumaudio.backends.providers.pennylane_backend import ( + PennyLaneBackend, + ) + + registry.register("pennylane", PennyLaneBackend) diff --git a/quantumaudio/backends/providers/pennylane_backend.py b/quantumaudio/backends/providers/pennylane_backend.py new file mode 100644 index 00000000..a75d9d30 --- /dev/null +++ b/quantumaudio/backends/providers/pennylane_backend.py @@ -0,0 +1,189 @@ +# 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 + + +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) + return { + "kernel_fn": kernel_fn, + "measured_wires": measured_wires, + "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 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 + ) + + @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/tests/test_backends.py b/tests/test_backends.py index 3b6eb76e..b677ef3d 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -524,6 +524,52 @@ def test_statevector(self): 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 + + # ====================================================================== # Cross-backend scheme round-trips # ====================================================================== @@ -532,6 +578,8 @@ def test_statevector(self): BACKENDS = ["qiskit"] if is_available("cirq"): BACKENDS.append("cirq") +if is_available("pennylane"): + BACKENDS.append("pennylane") @pytest.fixture(params=BACKENDS) From d8a46e3f41257ff79344314576d0c1fb841dc86f Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Tue, 28 Apr 2026 13:37:59 +0200 Subject: [PATCH 14/19] feat(backends): add NVIDIA CUDA-Q backend provider --- README.md | 16 ++ pyproject.toml | 1 + quantumaudio/backends/providers/__init__.py | 7 + .../backends/providers/cudaq_backend.py | 202 ++++++++++++++++++ tests/test_backends.py | 62 ++++++ 5 files changed, 288 insertions(+) create mode 100644 quantumaudio/backends/providers/cudaq_backend.py diff --git a/README.md b/README.md index fa9af615..a003622c 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,22 @@ out = scheme.decode_counts(result.counts, metadata=spec.metadata) A single `PennyLaneBackend` reaches every vendor PennyLane has a plugin for; install the corresponding plugin (`pennylane-ionq`, `amazon-braket-pennylane-plugin`, ...) for your target. +For CUDA-Q-supported hardware (IonQ, Quantinuum, IQM, Anyon, ORCA, Pasqal, Rigetti, QuEra, Braket, etc.) via NVIDIA's runtime, construct `CudaQBackend` with the target name: + +```python +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) +``` + +`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. + 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: - **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), diff --git a/pyproject.toml b/pyproject.toml index 3b7bc42b..f3b5246f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ 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/backends/providers/__init__.py b/quantumaudio/backends/providers/__init__.py index b7eb0884..664acbd6 100644 --- a/quantumaudio/backends/providers/__init__.py +++ b/quantumaudio/backends/providers/__init__.py @@ -38,3 +38,10 @@ ) 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/cudaq_backend.py b/quantumaudio/backends/providers/cudaq_backend.py new file mode 100644 index 00000000..0433f3a1 --- /dev/null +++ b/quantumaudio/backends/providers/cudaq_backend.py @@ -0,0 +1,202 @@ +# 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") + + +# 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() + 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/tests/test_backends.py b/tests/test_backends.py index b677ef3d..6f3245da 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -570,6 +570,66 @@ def test_statevector(self): 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 # ====================================================================== @@ -580,6 +640,8 @@ def test_statevector(self): BACKENDS.append("cirq") if is_available("pennylane"): BACKENDS.append("pennylane") +if is_available("cudaq"): + BACKENDS.append("cudaq") @pytest.fixture(params=BACKENDS) From 9202cfa6f07e39557ca81198544be070d350f58a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 2 May 2026 10:36:40 +0200 Subject: [PATCH 15/19] feat(backends): give CUDA-Q kernels meaningful job names --- .../backends/providers/cudaq_backend.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/quantumaudio/backends/providers/cudaq_backend.py b/quantumaudio/backends/providers/cudaq_backend.py index 0433f3a1..9710cb6c 100644 --- a/quantumaudio/backends/providers/cudaq_backend.py +++ b/quantumaudio/backends/providers/cudaq_backend.py @@ -28,6 +28,41 @@ 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 @@ -163,6 +198,17 @@ def build_circuit(self, spec: CircuitSpec) -> dict: :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) From 66ad5051b4d6cbe4d646ce0c9fbb5abc9c4eab3b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 2 May 2026 11:04:14 +0200 Subject: [PATCH 16/19] feat(backends): give PennyLane-submitted IonQ jobs meaningful names --- .../backends/providers/pennylane_backend.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/quantumaudio/backends/providers/pennylane_backend.py b/quantumaudio/backends/providers/pennylane_backend.py index a75d9d30..6d77c6d3 100644 --- a/quantumaudio/backends/providers/pennylane_backend.py +++ b/quantumaudio/backends/providers/pennylane_backend.py @@ -109,6 +109,28 @@ def kernel_fn() -> None: 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. @@ -134,10 +156,18 @@ def build_circuit(self, spec: CircuitSpec) -> dict: :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() @@ -156,6 +186,7 @@ def run( dev = qml.device( self._device, wires=n, **self._device_kwargs ) + _maybe_name_remote_job(dev, native_circuit["job_name"]) @qml.qnode(dev) def node(): From 6a04e50665df218f79b928cd37113d9bd083a082 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 2 May 2026 11:24:26 +0200 Subject: [PATCH 17/19] docs: rewrite Native and External Backends sections for backend layer --- README.md | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a003622c..70bb2729 100644 --- a/README.md +++ b/README.md @@ -187,40 +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="...")`: -### Running on External Quantum Backends - -For PennyLane-supported hardware (IonQ, Amazon Braket, Rigetti, Quantinuum, IQM, Pasqal, ...), construct `PennyLaneBackend` with the device name, then drive it through `build_circuit` / `run` / `decode_counts`: +| `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 -from quantumaudio.backends.providers.pennylane_backend import PennyLaneBackend - -be = PennyLaneBackend(device="ionq.simulator") # or "ionq.qpu" -native = be.build_circuit(spec) -result = be.run(native, shots=1024) -out = scheme.decode_counts(result.counts, metadata=spec.metadata) +spec = scheme.encode(audio) +out = scheme.decode(spec, backend="cudaq", shots=8000) ``` -A single `PennyLaneBackend` reaches every vendor PennyLane has a plugin for; install the corresponding plugin (`pennylane-ionq`, `amazon-braket-pennylane-plugin`, ...) for your target. +`scheme.decode(..., backend="")` always runs **locally**. Reaching the cloud requires constructing a backend instance directly with a vendor device/target name, see below. -For CUDA-Q-supported hardware (IonQ, Quantinuum, IQM, Anyon, ORCA, Pasqal, Rigetti, QuEra, Braket, etc.) via NVIDIA's runtime, construct `CudaQBackend` with the target name: +### Running on External Quantum Backends + +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" -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) ``` -`CudaQBackend` reaches IonQ, Quantinuum, IQM, Anyon, ORCA, Pasqal, Rigetti, QuEra, Braket, etc. via NVIDIA's runtime. +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. -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: +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. @@ -256,6 +265,9 @@ The default process function of `stream()` simply encodes and decodes a chunk of processed_data = process_function(data, scheme, **kwargs) ``` +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. From 1d12b5e949a0ad68d35bc020d5df5939282b7953 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Tue, 19 May 2026 09:10:48 +0200 Subject: [PATCH 18/19] fix(quantumaudio): resolve pre-existing ruff lint findings --- quantumaudio/__init__.py | 26 +++++++++++++------------- quantumaudio/interfaces/api.py | 20 ++++++++++++++------ quantumaudio/schemes/__init__.py | 2 +- quantumaudio/schemes/msqpam.py | 3 +-- quantumaudio/schemes/sqpam.py | 3 +-- quantumaudio/tools/plot.py | 7 +++---- quantumaudio/tools/stream.py | 11 +++++++---- quantumaudio/utils/execute.py | 8 ++++++-- 8 files changed, 46 insertions(+), 34 deletions(-) diff --git a/quantumaudio/__init__.py b/quantumaudio/__init__.py index 1b1de485..2bc1cdc9 100644 --- a/quantumaudio/__init__.py +++ b/quantumaudio/__init__.py @@ -114,20 +114,20 @@ def __dir__(): ] __all__ = [ - "schemes", - "utils", - "tools", + "MQSM", + "MSQPAM", + "QPAM", + "QSM", + "SQPAM", "backends", - "load_scheme", - "encode", - "decode", - "stream", "calculate", - "decode_result", + "decode", "decode_counts", - "QPAM", - "SQPAM", - "QSM", - "MSQPAM", - "MQSM", + "decode_result", + "encode", + "load_scheme", + "schemes", + "stream", + "tools", + "utils", ] diff --git a/quantumaudio/interfaces/api.py b/quantumaudio/interfaces/api.py index 64bcfeae..ca7f8180 100644 --- a/quantumaudio/interfaces/api.py +++ b/quantumaudio/interfaces/api.py @@ -17,11 +17,16 @@ without explicitly instantiating a Scheme class. They are made directly accessible from `quantumaudio`. """ +from typing import TYPE_CHECKING, Optional, Union + import quantumaudio from quantumaudio import load_scheme -from quantumaudio.utils import pick_key from quantumaudio.tools import stream_data -from typing import Union, Optional +from quantumaudio.utils import pick_key + +if TYPE_CHECKING: + import numpy as np + import qiskit # ------------------- Core Functions --------------------------- @@ -41,7 +46,8 @@ def encode( Returns: Qiskit circuit encoding the data. """ - if not scheme: scheme = _auto_pick_scheme(data) + if not scheme: + scheme = _auto_pick_scheme(data) scheme_kwargs, kwargs = _split_kwargs(kwargs) return _load_scheme(scheme, **scheme_kwargs).encode(data, **kwargs) @@ -73,13 +79,14 @@ def stream( Args: data: Data to be streamed. scheme: Name of the quantum scheme to use for streaming. - **kwargs: Additional keyword arguments passed to the streaming method. + **kwargs: Additional keyword arguments passed to the streaming method. Refer to :func:`quantumaudio.tools.stream.stream_data` for all arguments. Returns: Processed stream data based on the quantum scheme. """ - if not scheme: scheme = _auto_pick_scheme(data) + if not scheme: + scheme = _auto_pick_scheme(data) scheme_kwargs, kwargs = _split_kwargs(kwargs) scheme = _load_scheme(scheme, **scheme_kwargs) return stream_data(data=data, scheme=scheme, **kwargs) @@ -100,7 +107,8 @@ def calculate( scheme: Name of the encoding scheme or a scheme object to use. Defaults to "qpam". **kwargs: Additional keyword arguments passed to the scheme class. """ - if not scheme: scheme = _auto_pick_scheme(data) + if not scheme: + scheme = _auto_pick_scheme(data) _load_scheme(scheme, **kwargs).calculate(data) diff --git a/quantumaudio/schemes/__init__.py b/quantumaudio/schemes/__init__.py index d24c5d69..14ffb43f 100644 --- a/quantumaudio/schemes/__init__.py +++ b/quantumaudio/schemes/__init__.py @@ -136,4 +136,4 @@ def __dir__(): return __all__ -__all__ = ["QPAM", "SQPAM", "QSM", "MSQPAM", "MQSM", "Scheme"] +__all__ = ["MQSM", "MSQPAM", "QPAM", "QSM", "SQPAM", "Scheme"] diff --git a/quantumaudio/schemes/msqpam.py b/quantumaudio/schemes/msqpam.py index 3fd7d863..aa11b130 100644 --- a/quantumaudio/schemes/msqpam.py +++ b/quantumaudio/schemes/msqpam.py @@ -318,13 +318,12 @@ def decode_components( cosine_amps = np.zeros(num_components) sine_amps = np.zeros(num_components) - for state in counts: + for state, value in counts.items(): index_bits, channel_bits, value_bits = utils.split_string( state, qubit_shape ) index = int(index_bits, 2) channel = int(channel_bits, 2) - value = counts[state] if value_bits == "0": cosine_amps[channel][index] = value elif value_bits == "1": diff --git a/quantumaudio/schemes/sqpam.py b/quantumaudio/schemes/sqpam.py index 7b18a708..bf479e5b 100644 --- a/quantumaudio/schemes/sqpam.py +++ b/quantumaudio/schemes/sqpam.py @@ -259,10 +259,9 @@ def decode_components( sine_amps = np.zeros(num_components) # getting components from counts - for state in counts: + for state, value in counts.items(): index_bits, value_bits = utils.split_string(state, qubit_shape) index = int(index_bits, 2) - value = counts[state] if value_bits == "0": cosine_amps[index] = value elif value_bits == "1": diff --git a/quantumaudio/tools/plot.py b/quantumaudio/tools/plot.py index 3bb61628..6600b623 100644 --- a/quantumaudio/tools/plot.py +++ b/quantumaudio/tools/plot.py @@ -89,7 +89,7 @@ def plot( x_axis = np.arange(0, num_samples) if num_channels > 1: - fig, axs = plt.subplots(num_channels, 1, figsize=figsize) + _, axs = plt.subplots(num_channels, 1, figsize=figsize) for i, y_axis in enumerate(samples): for c in range(num_channels): axs[c].plot( @@ -107,9 +107,8 @@ def plot( else: plt.figure(figsize=figsize) - for i, y_axis in enumerate(samples): - if isinstance(y_axis, np.ndarray): - y_axis = y_axis.squeeze() + for i, raw_y in enumerate(samples): + y_axis = raw_y.squeeze() if isinstance(raw_y, np.ndarray) else raw_y plt.plot(x_axis, y_axis, label=None if not label else label[i]) plt.xlabel("Index") plt.ylabel("Values") diff --git a/quantumaudio/tools/stream.py b/quantumaudio/tools/stream.py index 0c197033..c423ea10 100644 --- a/quantumaudio/tools/stream.py +++ b/quantumaudio/tools/stream.py @@ -13,11 +13,14 @@ # limitations under the License. # ========================================================================== -from typing import Any, Callable, Union +from typing import TYPE_CHECKING, Any, Callable, Union import numpy as np from tqdm import tqdm +if TYPE_CHECKING: + import quantumaudio + # ====================== # Buffering Functions # ====================== @@ -132,13 +135,13 @@ def combine_chunks(chunks: list[np.ndarray]) -> np.ndarray: else: output = np.concatenate(chunks, axis=0) return output - except: - print("Warning: Chunks cannot be combined.") # if different data type + except Exception: + print("Warning: Chunks cannot be combined.") # if different data type return chunks def normalize(data: np.ndarray) -> np.ndarray: """Normalize the input data to ensure it lies within the standard range [-1.0, 1.0]. - + Args: data: Input array containing audio data. """ diff --git a/quantumaudio/utils/execute.py b/quantumaudio/utils/execute.py index 3de236dc..b58f2cd1 100644 --- a/quantumaudio/utils/execute.py +++ b/quantumaudio/utils/execute.py @@ -13,10 +13,14 @@ # limitations under the License. # ========================================================================== +import importlib +from typing import TYPE_CHECKING, Any, Type + import qiskit_aer from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager -from typing import Type, Any -import importlib + +if TYPE_CHECKING: + import qiskit # Optional Import if exists _Sampler = ( From 6d2202a30719a7ef46685d2cde4730bf4c9ea0e6 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 17 May 2026 06:11:17 +0200 Subject: [PATCH 19/19] ci: add GitHub Actions workflow for lint and tests Runs `ruff check` plus `pytest` across Python 3.9-3.12 with the `cirq`, `pennylane`, and `cudaq` extras installed. --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++++++++++++ .ruff.toml | 1 + pyproject.toml | 3 +++ 3 files changed, 42 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5038b50e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install ruff + - run: ruff check . + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: pip install ".[cirq]" pytest + - run: pip install ".[pennylane]" + if: matrix.python-version != '3.9' + - run: pip install ".[cudaq]" + if: matrix.python-version != '3.9' && matrix.python-version != '3.10' + - run: python -m pytest diff --git a/.ruff.toml b/.ruff.toml index eac3ca1b..52cd512d 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -37,4 +37,5 @@ ignore = [ exclude = [ "tests/*.py", "scripts/*.py", + "demos/**", ] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index f3b5246f..275f8b16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,3 +48,6 @@ demos = [ "librosa==0.10.2.post1", "ipywidgets" ] + +[tool.setuptools.packages.find] +include = ["quantumaudio*"]