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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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