diff --git a/docs/api/qumat_qdp.yml b/docs/api/qumat_qdp.yml index 2412a528e6..1b8ff7d05e 100644 --- a/docs/api/qumat_qdp.yml +++ b/docs/api/qumat_qdp.yml @@ -23,6 +23,7 @@ loaders: - qumat_qdp.backend - qumat_qdp.tensor - qumat_qdp.loader + - qumat_qdp.preprocessing - qumat_qdp.triton_amd # Generated QDP docs intentionally load implementation modules so pydoc-markdown diff --git a/docs/api/qumat_qdp_header.md b/docs/api/qumat_qdp_header.md index 14cb9b5f04..fbaff50c63 100644 --- a/docs/api/qumat_qdp_header.md +++ b/docs/api/qumat_qdp_header.md @@ -19,3 +19,4 @@ return values, and exceptions. - [QuantumDataLoader](#quantumdataloader) — build iterable encoding pipelines - [QdpBenchmark](#qdpbenchmark) — measure throughput and latency - [QdpTensor](#qdptensor) — exchange encoded results through DLPack +- [Preprocessor](#preprocessor) — validate inputs and compute L2 norms on the CPU diff --git a/docs/qdp/python-api.md b/docs/qdp/python-api.md index b3d6ec1d8e..2bbdedd8df 100644 --- a/docs/qdp/python-api.md +++ b/docs/qdp/python-api.md @@ -10,6 +10,7 @@ sidebar_label: Python API - A unified encoding facade via `QdpEngine` - Benchmark and loader builders - Backend-selection helpers +- CPU-side input validation and L2 norms via `Preprocessor` - Optional access to native `_qdp` types when the Rust extension is installed ## Overview @@ -22,6 +23,7 @@ from qumat_qdp import ( Backend, LatencyResult, NativeQuantumTensor, + Preprocessor, QdpBenchmark, QdpEngine, QdpTensor, @@ -42,6 +44,7 @@ Key exports: - `QdpBenchmark`: benchmark builder for the Rust pipeline or explicit PyTorch reference backend. - `QuantumDataLoader`: batch iterator builder for synthetic or file-backed inputs. - `QdpTensor` / `QuantumTensor`: thin DLPack facade type. +- `Preprocessor`: CPU-side input checks and L2 norms (the same checks the encoders run before using the GPU). - `Backend`, `BACKEND`, `force_backend`: backend detection and override helpers for the `_qdp` / PyTorch-reference selection layer. - `TritonAmdEngine`, `is_triton_amd_available`: direct AMD-route entry points. - `RustQdpEngine`, `NativeQuantumTensor`: native `_qdp` exports when the extension is available. @@ -151,6 +154,78 @@ qt = QdpEngine(device_id=0, backend="cuda").encode( tensor = torch.from_dlpack(qt) ``` +## Preprocessing API + +`Preprocessor` exposes the CPU-side checks that the native encoders run +before any data reaches the GPU. Use it to fail fast on bad input, or to +see the norm that amplitude encoding divides by. All four methods are +static, run on the CPU, and need the `_qdp` extension but not a GPU (a +stub build without the CUDA toolkit works). + +| Method | Returns | Checks | +|--------|---------|--------| +| `validate_input(data, num_qubits)` | `None` | `1 <= num_qubits <= 30`, `data` non-empty, `len(data) <= 2**num_qubits` | +| `calculate_l2_norm(data)` | `float` | norm is non-zero and finite (no NaN / Inf) | +| `validate_batch(batch_data, num_samples, sample_size, num_qubits)` | `None` | `num_samples > 0`, length is `num_samples * sample_size`, `sample_size <= 2**num_qubits` | +| `calculate_batch_l2_norms(batch_data, num_samples, sample_size)` | `numpy.ndarray` (float64, one norm per sample) | every sample norm is non-zero and finite | + +Input notes: + +- `data` is a 1D NumPy array or a sequence of numbers. Other dtypes are + converted to `float64`; a C-contiguous `float64` array is used without + a copy. +- `batch_data` is either 2D with shape `(num_samples, sample_size)` or the + same values flattened to 1D in row-major order. Non-contiguous and + Fortran-ordered arrays are read in logical row order. +- A failed check raises `RuntimeError` whose message starts with + `"Invalid input:"` (the same message `QdpEngine.encode` reports). A 1D + argument given a 2D array raises `TypeError`; values NumPy cannot + convert to `float64` (strings, ragged lists) raise `ValueError`. + +### Why the norm matters for amplitude encoding + +Amplitude encoding stores a feature vector `x` as the amplitudes of an +`n`-qubit state. A quantum state must have total probability 1, so QDP +loads `x / ||x||` rather than `x`, padding with zeros up to `2**n` +entries. `calculate_l2_norm` returns that `||x||`. A vector of all zeros +has norm 0 and cannot be normalized, so it is rejected instead of being +encoded as an invalid state; NaN or infinite values are rejected for the +same reason. + +```python +import numpy as np +from qumat_qdp import Preprocessor + +x = np.array([3.0, 4.0]) # 2 features -> fits in 1 qubit (2**1 = 2) + +Preprocessor.validate_input(x, num_qubits=1) # raises on bad input +norm = Preprocessor.calculate_l2_norm(x) # 5.0 +amplitudes = x / norm # [0.6, 0.8] +print(norm, amplitudes, (amplitudes**2).sum()) # probabilities sum to 1 + +try: + Preprocessor.calculate_l2_norm([0.0, 0.0]) +except RuntimeError as err: + print(err) # Invalid input: Input data has zero norm +``` + +`QdpEngine(...).encode(x, num_qubits=1)` on a GPU produces the state with +the same amplitudes `[0.6, 0.8]` (as complex numbers). + +Batch form, for a 2D array of samples: + +```python +batch = np.array([[3.0, 4.0], [1.0, 1.0]]) +Preprocessor.validate_batch(batch, num_samples=2, sample_size=2, num_qubits=1) +norms = Preprocessor.calculate_batch_l2_norms(batch, 2, 2) # [5.0, 1.414...] +states = batch / norms[:, None] +``` + +Naming note: these method names mirror the Rust +`qdp_core::preprocessing::Preprocessor` API and are the stable public +names. If they are ever renamed, the old names should keep working with a +`DeprecationWarning` for at least one release rather than being removed. + ## Benchmark API `QdpBenchmark` is a builder for throughput and latency runs. diff --git a/qdp/qdp-python/qumat_qdp/__init__.py b/qdp/qdp-python/qumat_qdp/__init__.py index 81ffd66347..1890839e28 100644 --- a/qdp/qdp-python/qumat_qdp/__init__.py +++ b/qdp/qdp-python/qumat_qdp/__init__.py @@ -19,12 +19,14 @@ Public API: QdpEngine (unified router), QdpTensor/QuantumTensor (DLPack facade), QdpBenchmark, ThroughputResult, LatencyResult (benchmark API), -QuantumDataLoader (data loader iterator). +QuantumDataLoader (data loader iterator), Preprocessor (CPU input +validation and L2 norms). Usage: from qumat_qdp import QdpEngine, QuantumTensor from qumat_qdp import QdpBenchmark, ThroughputResult, LatencyResult from qumat_qdp import QuantumDataLoader + from qumat_qdp import Preprocessor """ from __future__ import annotations @@ -72,6 +74,7 @@ def is_cuda_available() -> bool: ) from qumat_qdp.backend import QdpEngine from qumat_qdp.loader import QuantumDataLoader +from qumat_qdp.preprocessing import Preprocessor from qumat_qdp.tensor import QdpTensor, QuantumTensor from qumat_qdp.triton_amd import TritonAmdEngine, is_triton_amd_available @@ -80,6 +83,7 @@ def is_cuda_available() -> bool: "Backend", "LatencyResult", "NativeQuantumTensor", + "Preprocessor", "QdpBenchmark", "QdpEngine", "QdpTensor", diff --git a/qdp/qdp-python/qumat_qdp/preprocessing.py b/qdp/qdp-python/qumat_qdp/preprocessing.py new file mode 100644 index 0000000000..67a64573d8 --- /dev/null +++ b/qdp/qdp-python/qumat_qdp/preprocessing.py @@ -0,0 +1,162 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +"""CPU-side input validation and L2 norms used before QDP encoding. + +``Preprocessor`` exposes the same checks the native encoders run before any +data is copied to the GPU, so callers can validate inputs (and inspect the +norm amplitude encoding will divide by) without a GPU. The work is done by +the Rust ``_qdp`` extension; this module only adds documentation and a clear +error when the extension is not installed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from qumat_qdp._backend import get_qdp + +if TYPE_CHECKING: + import numpy as np + from numpy.typing import ArrayLike + +_MISSING_EXTENSION_MSG = ( + "qumat_qdp.Preprocessor requires the _qdp Rust extension. " + "Build it with: maturin develop --manifest-path qdp/qdp-python/Cargo.toml" +) + + +def _native() -> Any: + qdp = get_qdp() + native = getattr(qdp, "Preprocessor", None) if qdp is not None else None + if native is None: + raise RuntimeError(_MISSING_EXTENSION_MSG) + return native + + +class Preprocessor: + """Validate classical input and compute L2 norms before encoding. + + All methods are static and run on the CPU; no GPU or CUDA toolkit is + needed. Inputs may be NumPy arrays or plain Python sequences. Values are + converted to ``float64`` (a C-contiguous ``float64`` array is used without + a copy). Every method raises ``RuntimeError`` with a message starting + with ``"Invalid input:"`` when a check fails, matching the errors that + ``QdpEngine.encode`` reports for the same input. + + Example -- check a sample, then compute the amplitudes that amplitude + encoding will load:: + + import numpy as np + from qumat_qdp import Preprocessor + + x = np.array([3.0, 4.0]) # 2 features + Preprocessor.validate_input(x, num_qubits=1) + norm = Preprocessor.calculate_l2_norm(x) # 5.0 + amplitudes = x / norm # [0.6, 0.8] + + Amplitude encoding stores ``x / ||x||`` so that the squared amplitudes + (measurement probabilities) sum to 1. A zero vector has no such + normalization, so it is rejected rather than silently encoded. + + :raises RuntimeError: From any method, if the ``_qdp`` extension is not + installed. + """ + + @staticmethod + def validate_input(data: ArrayLike, num_qubits: int) -> None: + """Check that one sample can be encoded with ``num_qubits`` qubits. + + :param data: One sample as a 1D array or sequence of floats. + :param num_qubits: Number of qubits in the target state, between 1 + and 30 inclusive. + :returns: ``None`` when the input is valid. + :raises RuntimeError: If ``num_qubits`` is 0 or greater than 30, if + ``data`` is empty, or if ``len(data) > 2**num_qubits``. + :raises TypeError: If ``data`` is not one-dimensional. + :raises ValueError: If ``data`` holds values NumPy cannot convert to + ``float64`` (for example strings or ragged nested lists). + """ + _native().validate_input(data, num_qubits) + + @staticmethod + def calculate_l2_norm(data: ArrayLike) -> float: + """Return the Euclidean (L2) norm ``sqrt(sum(x_i ** 2))`` of a sample. + + This is the value amplitude encoding divides by. The sum is computed + in parallel, so the last bits may differ from ``numpy.linalg.norm``. + + :param data: One sample as a 1D array or sequence of floats. + :returns: The norm as a positive, finite Python ``float``. + :raises RuntimeError: If the norm is zero (including empty input), or + if ``data`` contains NaN or infinite values. + :raises TypeError: If ``data`` is not one-dimensional. + :raises ValueError: If ``data`` holds values NumPy cannot convert to + ``float64`` (for example strings or ragged nested lists). + """ + return _native().calculate_l2_norm(data) + + @staticmethod + def validate_batch( + batch_data: ArrayLike, + num_samples: int, + sample_size: int, + num_qubits: int, + ) -> None: + """Check that a batch of samples can be encoded with ``num_qubits``. + + :param batch_data: Either a 2D array of shape + ``(num_samples, sample_size)`` or the same values flattened to 1D + in row-major order. + :param num_samples: Number of samples in the batch; must be > 0. + :param sample_size: Number of values in each sample; must be at most + ``2**num_qubits``. + :param num_qubits: Number of qubits in each target state, between 1 + and 30 inclusive. + :returns: ``None`` when the batch is valid. + :raises RuntimeError: If ``num_samples`` is 0, if the data length is + not ``num_samples * sample_size`` (or a 2D shape disagrees), if + ``num_qubits`` is out of range, or if + ``sample_size > 2**num_qubits``. + :raises ValueError: If ``batch_data`` holds values NumPy cannot + convert to ``float64``. + """ + _native().validate_batch(batch_data, num_samples, sample_size, num_qubits) + + @staticmethod + def calculate_batch_l2_norms( + batch_data: ArrayLike, + num_samples: int, + sample_size: int, + ) -> np.ndarray: + """Return the L2 norm of every sample in a batch. + + :param batch_data: Either a 2D array of shape + ``(num_samples, sample_size)`` or the same values flattened to 1D + in row-major order. + :param num_samples: Number of samples in the batch; must be > 0. + :param sample_size: Number of values in each sample; must be > 0. + :returns: A 1D ``float64`` NumPy array of length ``num_samples``. + :raises RuntimeError: If the data length does not equal + ``num_samples * sample_size``, or if any sample has a zero, NaN or + infinite norm (the message names the first bad sample index). + :raises ValueError: If ``batch_data`` holds values NumPy cannot + convert to ``float64``. + """ + return _native().calculate_batch_l2_norms(batch_data, num_samples, sample_size) + + +__all__ = ["Preprocessor"] diff --git a/qdp/qdp-python/src/lib.rs b/qdp/qdp-python/src/lib.rs index 0bfd77189e..5043c78469 100644 --- a/qdp/qdp-python/src/lib.rs +++ b/qdp/qdp-python/src/lib.rs @@ -18,10 +18,12 @@ mod constants; mod dlpack; mod engine; mod loader; +mod preprocessing; mod pytorch; mod tensor; use engine::QdpEngine; +use preprocessing::PyPreprocessor; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use tensor::QuantumTensor; @@ -90,6 +92,7 @@ fn _qdp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(cuda_available, m)?)?; #[cfg(target_os = "linux")] m.add_class::()?; diff --git a/qdp/qdp-python/src/preprocessing.rs b/qdp/qdp-python/src/preprocessing.rs new file mode 100644 index 0000000000..357c3182b4 --- /dev/null +++ b/qdp/qdp-python/src/preprocessing.rs @@ -0,0 +1,160 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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. + +//! Python bindings for `qdp_core::preprocessing::Preprocessor`. +//! +//! These are pure CPU helpers (no CUDA calls), so they work in a stub build +//! of the extension on a machine without a GPU. + +use std::borrow::Cow; + +use numpy::ndarray::Dimension; +use numpy::{ + AllowTypeChange, IntoPyArray, PyArray1, PyArrayLike1, PyArrayLikeDyn, PyReadonlyArray, + PyUntypedArrayMethods, +}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use qdp_core::MahoutError; +use qdp_core::preprocessing::Preprocessor as CorePreprocessor; + +/// Map a core error to the Python exception type used by the rest of the +/// bindings (see `engine.rs`): `RuntimeError` carrying the core message. +fn to_py_err(e: MahoutError) -> PyErr { + PyRuntimeError::new_err(e.to_string()) +} + +/// Borrow the array as a row-major `&[f64]`, copying only when needed. +/// +/// `PyReadonlyArray::as_slice` also succeeds for Fortran-ordered arrays and +/// then returns the elements in *memory* (column-major) order, which would +/// silently reorder a 2D batch. Only C-contiguous arrays are borrowed; any +/// other layout is copied in logical (row-major) order. +fn row_major<'a, D: Dimension>(arr: &'a PyReadonlyArray<'_, f64, D>) -> Cow<'a, [f64]> { + match arr.as_slice() { + Ok(slice) if arr.is_c_contiguous() => Cow::Borrowed(slice), + _ => Cow::Owned(arr.as_array().iter().copied().collect()), + } +} + +/// Flatten a 1D (already flat) or 2D `(num_samples, sample_size)` batch. +fn flatten_batch<'a>( + arr: &'a PyReadonlyArray<'_, f64, numpy::IxDyn>, + num_samples: usize, + sample_size: usize, +) -> PyResult> { + let shape = arr.shape(); + match shape.len() { + 1 => {} + 2 if shape[0] == num_samples && shape[1] == sample_size => {} + 2 => { + return Err(PyRuntimeError::new_err(format!( + "Invalid input: batch_data has shape ({}, {}) but num_samples={} \ + and sample_size={}", + shape[0], shape[1], num_samples, sample_size + ))); + } + n => { + return Err(PyRuntimeError::new_err(format!( + "Invalid input: batch_data must be 1D (flattened) or 2D \ + (num_samples, sample_size), got {}D", + n + ))); + } + } + Ok(row_major(arr)) +} + +/// CPU-side input validation and L2 norms shared by the QDP encoders. +/// +/// Static-method binding of ``qdp_core::preprocessing::Preprocessor``. +/// Pure CPU: works without a GPU, including in a CUDA-stub build. +#[pyclass(name = "Preprocessor", frozen)] +pub struct PyPreprocessor; + +#[pymethods] +impl PyPreprocessor { + /// Check a single sample against ``num_qubits`` (non-empty, at most + /// ``2**num_qubits`` values, 1 <= num_qubits <= 30). + /// + /// Raises RuntimeError if the input is invalid. + #[staticmethod] + #[pyo3(signature = (data, num_qubits))] + fn validate_input( + data: PyArrayLike1<'_, f64, AllowTypeChange>, + num_qubits: usize, + ) -> PyResult<()> { + let values = row_major(&data); + CorePreprocessor::validate_input(&values, num_qubits).map_err(to_py_err) + } + + /// Return the L2 norm of a single sample. + /// + /// Raises RuntimeError if the norm is zero, NaN or infinite. + #[staticmethod] + #[pyo3(signature = (data,))] + fn calculate_l2_norm(data: PyArrayLike1<'_, f64, AllowTypeChange>) -> PyResult { + let values = row_major(&data); + CorePreprocessor::calculate_l2_norm(&values).map_err(to_py_err) + } + + /// Check a batch of ``num_samples`` samples of ``sample_size`` values. + /// + /// Raises RuntimeError if the input is invalid. + #[staticmethod] + #[pyo3(signature = (batch_data, num_samples, sample_size, num_qubits))] + fn validate_batch( + batch_data: PyArrayLikeDyn<'_, f64, AllowTypeChange>, + num_samples: usize, + sample_size: usize, + num_qubits: usize, + ) -> PyResult<()> { + let values = flatten_batch(&batch_data, num_samples, sample_size)?; + CorePreprocessor::validate_batch(&values, num_samples, sample_size, num_qubits) + .map_err(to_py_err) + } + + /// Return the L2 norm of every sample in a batch as a float64 array. + /// + /// Raises RuntimeError if the shape is inconsistent or any sample has + /// a zero, NaN or infinite norm. + #[staticmethod] + #[pyo3(signature = (batch_data, num_samples, sample_size))] + fn calculate_batch_l2_norms<'py>( + py: Python<'py>, + batch_data: PyArrayLikeDyn<'py, f64, AllowTypeChange>, + num_samples: usize, + sample_size: usize, + ) -> PyResult>> { + let values = flatten_batch(&batch_data, num_samples, sample_size)?; + // The core helper chunks by `sample_size` without checking the + // length; `par_chunks(0)` panics, and the release profile aborts on + // panic, so guard here instead of taking the interpreter down. + let expected = num_samples.checked_mul(sample_size); + if sample_size == 0 || num_samples == 0 || expected != Some(values.len()) { + return Err(PyRuntimeError::new_err(format!( + "Invalid input: batch_data length {} doesn't match num_samples {} * \ + sample_size {} (both must be > 0)", + values.len(), + num_samples, + sample_size + ))); + } + let norms = CorePreprocessor::calculate_batch_l2_norms(&values, num_samples, sample_size) + .map_err(to_py_err)?; + Ok(norms.into_pyarray(py)) + } +} diff --git a/testing/qdp/test_preprocessor.py b/testing/qdp/test_preprocessor.py new file mode 100644 index 0000000000..d0d7f2e141 --- /dev/null +++ b/testing/qdp/test_preprocessor.py @@ -0,0 +1,267 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 qumat_qdp.Preprocessor (CPU-only; no GPU required). + +These need the _qdp extension (auto-skipped by testing/conftest.py when it is +missing) but never touch CUDA, so they run on a stub build too. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import numpy as np +import pytest +import qumat_qdp +from qumat_qdp import Preprocessor + +REPO_ROOT = Path(__file__).resolve().parents[2] +INVALID = r"Invalid input" + + +# --------------------------------------------------------------------------- +# Public API surface +# --------------------------------------------------------------------------- + + +def test_preprocessor_exported_in_all(): + assert "Preprocessor" in qumat_qdp.__all__ + assert qumat_qdp.Preprocessor is Preprocessor + + +@pytest.mark.parametrize( + "name", + [ + "validate_input", + "calculate_l2_norm", + "validate_batch", + "calculate_batch_l2_norms", + ], +) +def test_public_methods_have_docstrings(name): + doc = getattr(Preprocessor, name).__doc__ + assert doc, f"Preprocessor.{name} has no docstring" + assert ":raises RuntimeError:" in doc + + +def test_native_class_registered(): + import _qdp + + assert hasattr(_qdp, "Preprocessor") + + +def test_missing_extension_raises_clear_error(monkeypatch): + import qumat_qdp.preprocessing as mod + + monkeypatch.setattr(mod, "get_qdp", lambda: None) + with pytest.raises(RuntimeError, match="requires the _qdp Rust extension"): + Preprocessor.calculate_l2_norm([1.0]) + + +# --------------------------------------------------------------------------- +# validate_input +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("data", "num_qubits"), + [ + ([1.0], 1), + ([1.0, 2.0], 1), + ([1.0, 2.0, 3.0], 2), # shorter than 2**n is fine (zero-padded) + (np.ones(8), 3), + (np.ones(4, dtype=np.float32), 2), # converted to float64 + ((1, 2, 3, 4), 2), # tuple of ints + ], +) +def test_validate_input_accepts(data, num_qubits): + assert Preprocessor.validate_input(data, num_qubits) is None + + +@pytest.mark.parametrize( + ("data", "num_qubits", "message"), + [ + ([1.0], 0, "at least 1"), + ([1.0], 31, "exceeds practical limit"), + ([], 2, "cannot be empty"), + (np.ones(5), 2, "exceeds state vector size 4"), + ], +) +def test_validate_input_rejects(data, num_qubits, message): + with pytest.raises(RuntimeError, match=f"{INVALID}.*{message}"): + Preprocessor.validate_input(data, num_qubits) + + +def test_validate_input_rejects_2d(): + with pytest.raises(TypeError): + Preprocessor.validate_input(np.ones((2, 2)), 2) + + +def test_validate_input_rejects_non_numeric(): + with pytest.raises(ValueError, match="could not convert"): + Preprocessor.validate_input(["a"], 1) + + +# --------------------------------------------------------------------------- +# calculate_l2_norm +# --------------------------------------------------------------------------- + + +def test_calculate_l2_norm_value(): + assert Preprocessor.calculate_l2_norm([3.0, 4.0]) == pytest.approx(5.0) + + +def test_calculate_l2_norm_returns_float(): + assert isinstance(Preprocessor.calculate_l2_norm(np.array([1.0])), float) + + +def test_calculate_l2_norm_matches_numpy(): + rng = np.random.default_rng(0) + x = rng.standard_normal(1024) + assert Preprocessor.calculate_l2_norm(x) == pytest.approx(np.linalg.norm(x)) + + +def test_calculate_l2_norm_non_contiguous_view(): + x = np.arange(1.0, 9.0) + view = x[::2] # [1, 3, 5, 7], not contiguous + assert not view.flags["C_CONTIGUOUS"] + expected = np.linalg.norm(view) + assert Preprocessor.calculate_l2_norm(view) == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("data", "message"), + [ + ([0.0, 0.0, 0.0], "zero norm"), + ([], "zero norm"), + ([1.0, float("nan")], "NaN"), + ([1.0, float("inf")], "Infinity"), + ], +) +def test_calculate_l2_norm_rejects(data, message): + with pytest.raises(RuntimeError, match=f"{INVALID}.*{message}"): + Preprocessor.calculate_l2_norm(data) + + +# --------------------------------------------------------------------------- +# validate_batch +# --------------------------------------------------------------------------- + + +def test_validate_batch_accepts_2d_and_flat(): + batch = np.ones((3, 4)) + assert Preprocessor.validate_batch(batch, 3, 4, 2) is None + assert Preprocessor.validate_batch(batch.ravel(), 3, 4, 2) is None + assert Preprocessor.validate_batch(batch.ravel().tolist(), 3, 4, 2) is None + + +@pytest.mark.parametrize( + ("batch", "num_samples", "sample_size", "num_qubits", "message"), + [ + (np.ones(4), 0, 4, 2, "num_samples must be greater than 0"), + (np.ones(7), 2, 4, 2, "doesn't match num_samples 2 \\* sample_size 4"), + (np.ones((2, 4)), 4, 2, 2, "shape \\(2, 4\\)"), + (np.ones(8), 1, 8, 2, "Sample size 8 exceeds state vector size 4"), + (np.ones(4), 1, 4, 0, "at least 1"), + (np.ones((1, 1, 4)), 1, 4, 2, "must be 1D .* or 2D"), + ], +) +def test_validate_batch_rejects(batch, num_samples, sample_size, num_qubits, message): + with pytest.raises(RuntimeError, match=f"{INVALID}.*{message}"): + Preprocessor.validate_batch(batch, num_samples, sample_size, num_qubits) + + +# --------------------------------------------------------------------------- +# calculate_batch_l2_norms +# --------------------------------------------------------------------------- + + +def test_calculate_batch_l2_norms_values(): + batch = np.array([[3.0, 4.0], [6.0, 8.0], [1.0, 0.0]]) + norms = Preprocessor.calculate_batch_l2_norms(batch, 3, 2) + assert isinstance(norms, np.ndarray) + assert norms.dtype == np.float64 + np.testing.assert_allclose(norms, [5.0, 10.0, 1.0]) + + +def test_calculate_batch_l2_norms_flat_input(): + norms = Preprocessor.calculate_batch_l2_norms([3.0, 4.0, 6.0, 8.0], 2, 2) + np.testing.assert_allclose(norms, [5.0, 10.0]) + + +def test_calculate_batch_l2_norms_fortran_order_keeps_rows(): + """A Fortran-ordered 2D array must still be read row by row.""" + batch = np.asfortranarray([[3.0, 4.0, 0.0], [0.0, 0.0, 2.0]]) + assert not batch.flags["C_CONTIGUOUS"] + norms = Preprocessor.calculate_batch_l2_norms(batch, 2, 3) + np.testing.assert_allclose(norms, [5.0, 2.0]) + + +def test_calculate_batch_l2_norms_matches_numpy(): + rng = np.random.default_rng(1) + batch = rng.standard_normal((16, 32)) + norms = Preprocessor.calculate_batch_l2_norms(batch, 16, 32) + np.testing.assert_allclose(norms, np.linalg.norm(batch, axis=1)) + + +@pytest.mark.parametrize( + ("batch", "message"), + [ + ([[1.0, 1.0], [0.0, 0.0]], "Sample 1 has zero norm"), + ([[float("nan"), 1.0], [1.0, 1.0]], "Sample 0 produced NaN norm"), + ([[1.0, 1.0], [float("inf"), 1.0]], "Sample 1 produced Infinity norm"), + ], +) +def test_calculate_batch_l2_norms_rejects_bad_samples(batch, message): + with pytest.raises(RuntimeError, match=f"{INVALID}.*{message}"): + Preprocessor.calculate_batch_l2_norms(np.array(batch), 2, 2) + + +@pytest.mark.parametrize( + ("batch", "num_samples", "sample_size"), + [ + (np.ones(4), 2, 0), # would panic in par_chunks(0) without the guard + (np.ones(4), 0, 4), + (np.ones(5), 2, 2), + ], +) +def test_calculate_batch_l2_norms_rejects_bad_shape(batch, num_samples, sample_size): + with pytest.raises(RuntimeError, match=INVALID): + Preprocessor.calculate_batch_l2_norms(batch, num_samples, sample_size) + + +# --------------------------------------------------------------------------- +# The documented example must run as written. +# --------------------------------------------------------------------------- + + +def _doc_example() -> str: + text = (REPO_ROOT / "docs" / "qdp" / "python-api.md").read_text() + blocks = re.findall(r"```python\n(.*?)```", text, flags=re.DOTALL) + for block in blocks: + if "Preprocessor.validate_input" in block: + return block + raise AssertionError("Preprocessor example not found in docs/qdp/python-api.md") + + +def test_docs_example_runs(): + namespace: dict = {} + exec(compile(_doc_example(), "python-api.md", "exec"), namespace) + amplitudes = namespace["amplitudes"] + np.testing.assert_allclose(amplitudes, [0.6, 0.8]) + assert np.sum(amplitudes**2) == pytest.approx(1.0) diff --git a/testing/qdp/test_preprocessor_bindings.py b/testing/qdp/test_preprocessor_bindings.py new file mode 100644 index 0000000000..684eb98cfd --- /dev/null +++ b/testing/qdp/test_preprocessor_bindings.py @@ -0,0 +1,79 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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 raw ``_qdp.Preprocessor`` PyO3 binding (CPU-only). + +These tests pin down the native layer: argument conversion, error +mapping, and memory layout handling. No GPU is needed (works on a CUDA-stub build). +""" + +import numpy as np +import pytest + + +@pytest.fixture +def native(): + import _qdp + + return _qdp.Preprocessor + + +def test_static_methods_exist(native): + for name in ( + "validate_input", + "calculate_l2_norm", + "validate_batch", + "calculate_batch_l2_norms", + ): + assert callable(getattr(native, name)) + + +def test_l2_norm_list_and_array(native): + assert native.calculate_l2_norm([3.0, 4.0]) == pytest.approx(5.0) + assert native.calculate_l2_norm(np.array([3.0, 4.0])) == pytest.approx(5.0) + + +def test_int_and_float32_inputs_are_converted(native): + assert native.calculate_l2_norm([3, 4]) == pytest.approx(5.0) + x = np.array([3.0, 4.0], dtype=np.float32) + assert native.calculate_l2_norm(x) == pytest.approx(5.0) + + +def test_core_errors_map_to_runtime_error(native): + with pytest.raises(RuntimeError, match="Invalid input: Input data has zero norm"): + native.calculate_l2_norm([0.0, 0.0]) + with pytest.raises(RuntimeError, match="Invalid input: .*exceeds state vector"): + native.validate_input([1.0, 2.0, 3.0], 1) + + +def test_conversion_errors(native): + with pytest.raises(TypeError): + native.calculate_l2_norm(np.ones((2, 2))) # wrong rank + with pytest.raises(ValueError, match="could not convert"): + native.calculate_l2_norm(["a", "b"]) + + +def test_fortran_order_batch_is_read_row_major(native): + batch = np.asfortranarray([[3.0, 4.0, 0.0], [0.0, 0.0, 2.0]]) + np.testing.assert_allclose(native.calculate_batch_l2_norms(batch, 2, 3), [5.0, 2.0]) + + +def test_zero_sample_size_does_not_abort(native): + # Without the binding-side guard this reaches rayon's par_chunks(0), + # which panics; the release profile uses panic = "abort". + with pytest.raises(RuntimeError, match="Invalid input"): + native.calculate_batch_l2_norms(np.ones(4), 2, 0)