Skip to content

Commit ad3d2c6

Browse files
[QDP] Add angle and basis encoding tests for numpy input (#1179)
* test(qdp): add angle and basis encoding tests for numpy input refactor(qdp): centralize torch import and CUDA skip boilerplate in test_numpy Replace per-test pytest.importorskip("torch") and CUDA availability checks with a module-level importorskip and a shared autouse fixture. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> refactor(qdp): use pytest.param id= instead of unused description param test(qdp): add angle encoding correctness test for known angles * docs: self host katex.min.css to fix not loading issue (#1140) * fix(qdp): correct angle encoding expected probabilities to full-angle * revert(pre-commit): downgrade ruff from v0.15.6 to v0.15.5 --------- Co-authored-by: Tim Hsiung <bear890707@gmail.com>
1 parent 7fe002c commit ad3d2c6

2 files changed

Lines changed: 247 additions & 28 deletions

File tree

testing/qdp/test_numpy.py

Lines changed: 246 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@
2121

2222
import numpy as np
2323
import pytest
24-
import torch
24+
25+
torch = pytest.importorskip("torch")
2526

2627
from .qdp_test_utils import requires_qdp
2728

2829

30+
@pytest.fixture(autouse=True)
31+
def require_cuda():
32+
if not torch.cuda.is_available():
33+
pytest.skip("GPU required for QdpEngine")
34+
35+
2936
def _verify_tensor(tensor, expected_shape, check_normalization=False):
3037
"""Helper function to verify tensor properties"""
3138
assert tensor.shape == expected_shape, (
@@ -54,10 +61,6 @@ def test_encode_from_numpy_file(num_samples, num_qubits, check_norm):
5461
"""Test NumPy file encoding"""
5562
from _qdp import QdpEngine
5663

57-
pytest.importorskip("torch")
58-
if not torch.cuda.is_available():
59-
pytest.skip("GPU required for QdpEngine")
60-
6164
engine = QdpEngine(device_id=0)
6265
sample_size = 2**num_qubits
6366

@@ -89,10 +92,6 @@ def test_encode_numpy_array_1d(num_qubits):
8992
"""Test 1D NumPy array encoding (single sample)"""
9093
from _qdp import QdpEngine
9194

92-
pytest.importorskip("torch")
93-
if not torch.cuda.is_available():
94-
pytest.skip("GPU required for QdpEngine")
95-
9695
engine = QdpEngine(device_id=0)
9796
sample_size = 2**num_qubits
9897
data = np.random.randn(sample_size).astype(np.float64)
@@ -110,10 +109,6 @@ def test_encode_numpy_array_2d(num_samples, num_qubits):
110109
"""Test 2D NumPy array encoding (batch)"""
111110
from _qdp import QdpEngine
112111

113-
pytest.importorskip("torch")
114-
if not torch.cuda.is_available():
115-
pytest.skip("GPU required for QdpEngine")
116-
117112
engine = QdpEngine(device_id=0)
118113
sample_size = 2**num_qubits
119114
data = np.random.randn(num_samples, sample_size).astype(np.float64)
@@ -129,14 +124,9 @@ def test_encode_numpy_array_2d(num_samples, num_qubits):
129124
@pytest.mark.gpu
130125
@pytest.mark.parametrize("encoding_method", ["amplitude"])
131126
def test_encode_numpy_encoding_methods(encoding_method):
132-
"""Test different encoding methods"""
127+
"""Test amplitude encoding via encoding_method parameter"""
133128
from _qdp import QdpEngine
134129

135-
pytest.importorskip("torch")
136-
if not torch.cuda.is_available():
137-
pytest.skip("GPU required for QdpEngine")
138-
139-
# TODO: Add angle and basis encoding tests when implemented
140130
engine = QdpEngine(device_id=0)
141131
num_qubits = 2
142132
sample_size = 2**num_qubits
@@ -147,6 +137,243 @@ def test_encode_numpy_encoding_methods(encoding_method):
147137
_verify_tensor(tensor, (1, sample_size))
148138

149139

140+
# ---------------------------------------------------------------------------
141+
# Angle encoding tests
142+
# ---------------------------------------------------------------------------
143+
144+
145+
@requires_qdp
146+
@pytest.mark.gpu
147+
@pytest.mark.parametrize("num_qubits", [1, 2, 3, 4])
148+
def test_encode_numpy_angle_encoding_1d(num_qubits):
149+
"""Angle encoding: 1D array with num_qubits angles (single sample)"""
150+
from _qdp import QdpEngine
151+
152+
engine = QdpEngine(device_id=0)
153+
# Angle encoding expects exactly num_qubits values: one angle per qubit
154+
angles = np.random.uniform(0, 2 * np.pi, size=num_qubits).astype(np.float64)
155+
156+
qtensor = engine.encode(angles, num_qubits, encoding_method="angle")
157+
tensor = torch.from_dlpack(qtensor)
158+
159+
_verify_tensor(tensor, (1, 2**num_qubits), check_normalization=True)
160+
161+
162+
@requires_qdp
163+
@pytest.mark.gpu
164+
@pytest.mark.parametrize(("num_samples", "num_qubits"), [(5, 2), (10, 3), (1, 4)])
165+
def test_encode_numpy_angle_encoding_2d(num_samples, num_qubits):
166+
"""Angle encoding: 2D array of shape (num_samples, num_qubits)"""
167+
from _qdp import QdpEngine
168+
169+
engine = QdpEngine(device_id=0)
170+
angles = np.random.uniform(0, 2 * np.pi, size=(num_samples, num_qubits)).astype(
171+
np.float64
172+
)
173+
174+
qtensor = engine.encode(angles, num_qubits, encoding_method="angle")
175+
tensor = torch.from_dlpack(qtensor)
176+
177+
_verify_tensor(tensor, (num_samples, 2**num_qubits), check_normalization=True)
178+
179+
180+
@requires_qdp
181+
@pytest.mark.gpu
182+
def test_encode_numpy_angle_encoding_from_file():
183+
"""Angle encoding: load angles from .npy file"""
184+
from _qdp import QdpEngine
185+
186+
engine = QdpEngine(device_id=0)
187+
num_qubits = 3
188+
num_samples = 8
189+
angles = np.random.uniform(0, 2 * np.pi, size=(num_samples, num_qubits)).astype(
190+
np.float64
191+
)
192+
193+
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
194+
npy_path = f.name
195+
try:
196+
np.save(npy_path, angles)
197+
qtensor = engine.encode(npy_path, num_qubits, encoding_method="angle")
198+
tensor = torch.from_dlpack(qtensor)
199+
_verify_tensor(tensor, (num_samples, 2**num_qubits), check_normalization=True)
200+
finally:
201+
if os.path.exists(npy_path):
202+
os.remove(npy_path)
203+
204+
205+
@requires_qdp
206+
@pytest.mark.gpu
207+
def test_encode_numpy_angle_encoding_wrong_sample_size():
208+
"""Angle encoding: wrong sample_size raises an error"""
209+
from _qdp import QdpEngine
210+
211+
engine = QdpEngine(device_id=0)
212+
num_qubits = 3
213+
# Pass 2^num_qubits values instead of num_qubits values
214+
wrong_data = np.ones(2**num_qubits, dtype=np.float64)
215+
216+
with pytest.raises((RuntimeError, ValueError)):
217+
engine.encode(wrong_data, num_qubits, encoding_method="angle")
218+
219+
220+
@requires_qdp
221+
@pytest.mark.gpu
222+
@pytest.mark.parametrize(
223+
("theta", "expected_probs"),
224+
[
225+
pytest.param(0.0, [1.0, 0.0], id="theta=0"),
226+
pytest.param(np.pi / 2, [0.0, 1.0], id="theta=pi/2"),
227+
pytest.param(np.pi, [1.0, 0.0], id="theta=pi"),
228+
],
229+
)
230+
def test_encode_numpy_angle_encoding_1qubit_correctness(theta, expected_probs):
231+
"""Angle encoding: 1 qubit with known angle θ → probabilities [cos²(θ), sin²(θ)]"""
232+
from _qdp import QdpEngine
233+
234+
engine = QdpEngine(device_id=0, precision="float64")
235+
data = np.array([theta], dtype=np.float64)
236+
237+
qtensor = engine.encode(data, 1, encoding_method="angle")
238+
tensor = torch.from_dlpack(qtensor)
239+
240+
probs = tensor.abs().pow(2).squeeze(0) # shape: (2,)
241+
expected = torch.tensor(expected_probs, dtype=probs.dtype, device=probs.device)
242+
243+
assert torch.allclose(probs, expected, atol=1e-6), (
244+
f"For θ={theta}, expected {expected.cpu().tolist()}, got {probs.cpu().tolist()}"
245+
)
246+
247+
248+
# ---------------------------------------------------------------------------
249+
# Basis encoding tests
250+
# ---------------------------------------------------------------------------
251+
252+
253+
@requires_qdp
254+
@pytest.mark.gpu
255+
@pytest.mark.parametrize("num_qubits", [1, 2, 3, 4])
256+
def test_encode_numpy_basis_encoding_1d(num_qubits):
257+
"""Basis encoding: 1D array with a single index (single sample)"""
258+
from _qdp import QdpEngine
259+
260+
engine = QdpEngine(device_id=0)
261+
# Basis encoding expects sample_size=1: one integer index per sample
262+
index = np.array([float(2**num_qubits - 1)], dtype=np.float64)
263+
264+
qtensor = engine.encode(index, num_qubits, encoding_method="basis")
265+
tensor = torch.from_dlpack(qtensor)
266+
267+
_verify_tensor(tensor, (1, 2**num_qubits), check_normalization=True)
268+
269+
270+
@requires_qdp
271+
@pytest.mark.gpu
272+
@pytest.mark.parametrize(("num_samples", "num_qubits"), [(5, 2), (10, 3), (1, 4)])
273+
def test_encode_numpy_basis_encoding_2d(num_samples, num_qubits):
274+
"""Basis encoding: 2D array of shape (num_samples, 1) with integer indices"""
275+
from _qdp import QdpEngine
276+
277+
engine = QdpEngine(device_id=0)
278+
max_index = 2**num_qubits
279+
indices = np.random.randint(0, max_index, size=(num_samples, 1)).astype(np.float64)
280+
281+
qtensor = engine.encode(indices, num_qubits, encoding_method="basis")
282+
tensor = torch.from_dlpack(qtensor)
283+
284+
_verify_tensor(tensor, (num_samples, 2**num_qubits), check_normalization=True)
285+
286+
287+
@requires_qdp
288+
@pytest.mark.gpu
289+
def test_encode_numpy_basis_encoding_from_file():
290+
"""Basis encoding: load indices from .npy file"""
291+
from _qdp import QdpEngine
292+
293+
engine = QdpEngine(device_id=0)
294+
num_qubits = 3
295+
num_samples = 6
296+
indices = np.random.randint(0, 2**num_qubits, size=(num_samples, 1)).astype(
297+
np.float64
298+
)
299+
300+
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
301+
npy_path = f.name
302+
try:
303+
np.save(npy_path, indices)
304+
qtensor = engine.encode(npy_path, num_qubits, encoding_method="basis")
305+
tensor = torch.from_dlpack(qtensor)
306+
_verify_tensor(tensor, (num_samples, 2**num_qubits), check_normalization=True)
307+
finally:
308+
if os.path.exists(npy_path):
309+
os.remove(npy_path)
310+
311+
312+
@requires_qdp
313+
@pytest.mark.gpu
314+
@pytest.mark.parametrize(
315+
("basis_index", "num_qubits"),
316+
[
317+
(0, 2), # |00⟩: amplitude 1 at index 0
318+
(1, 2), # |01⟩: amplitude 1 at index 1
319+
(3, 2), # |11⟩: amplitude 1 at index 3
320+
(0, 3), # |000⟩: amplitude 1 at index 0
321+
(13, 4), # |1101⟩: amplitude 1 at index 13
322+
],
323+
)
324+
def test_encode_numpy_basis_state_correctness(basis_index, num_qubits):
325+
"""Basis encoding: verify the encoded state is exactly |basis_index⟩"""
326+
from _qdp import QdpEngine
327+
328+
engine = QdpEngine(device_id=0, precision="float64")
329+
data = np.array([[float(basis_index)]], dtype=np.float64)
330+
331+
qtensor = engine.encode(data, num_qubits, encoding_method="basis")
332+
tensor = torch.from_dlpack(qtensor)
333+
334+
# The encoded state should be a one-hot complex vector with |amplitude|²=1
335+
# at basis_index and 0 elsewhere
336+
probs = tensor.abs().pow(2).squeeze(0) # shape: (2^num_qubits,)
337+
expected = torch.zeros(2**num_qubits, dtype=probs.dtype, device=probs.device)
338+
expected[basis_index] = 1.0
339+
340+
assert torch.allclose(probs, expected, atol=1e-6), (
341+
f"Expected |{basis_index}⟩ but got probabilities {probs.cpu().tolist()}"
342+
)
343+
344+
345+
@requires_qdp
346+
@pytest.mark.gpu
347+
@pytest.mark.parametrize(
348+
"bad_data",
349+
[
350+
pytest.param(np.array([[-1.0]], dtype=np.float64), id="negative index"),
351+
pytest.param(np.array([[0.5]], dtype=np.float64), id="non-integer index"),
352+
],
353+
)
354+
def test_encode_numpy_basis_encoding_invalid_index(bad_data):
355+
"""Basis encoding: invalid index values raise an error"""
356+
from _qdp import QdpEngine
357+
358+
engine = QdpEngine(device_id=0)
359+
with pytest.raises((RuntimeError, ValueError)):
360+
engine.encode(bad_data, 2, encoding_method="basis")
361+
362+
363+
@requires_qdp
364+
@pytest.mark.gpu
365+
def test_encode_numpy_basis_encoding_out_of_range():
366+
"""Basis encoding: index >= 2^num_qubits raises an error"""
367+
from _qdp import QdpEngine
368+
369+
engine = QdpEngine(device_id=0)
370+
num_qubits = 2
371+
out_of_range = np.array([[float(2**num_qubits)]], dtype=np.float64) # index == 4
372+
373+
with pytest.raises((RuntimeError, ValueError)):
374+
engine.encode(out_of_range, num_qubits, encoding_method="basis")
375+
376+
150377
@requires_qdp
151378
@pytest.mark.gpu
152379
@pytest.mark.parametrize(
@@ -160,10 +387,6 @@ def test_encode_numpy_precision(precision, expected_dtype):
160387
"""Test different precision settings"""
161388
from _qdp import QdpEngine
162389

163-
pytest.importorskip("torch")
164-
if not torch.cuda.is_available():
165-
pytest.skip("GPU required for QdpEngine")
166-
167390
engine = QdpEngine(device_id=0, precision=precision)
168391
num_qubits = 2
169392
data = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
@@ -194,10 +417,6 @@ def test_encode_numpy_errors(data, error_match):
194417
"""Test error handling for invalid inputs"""
195418
from _qdp import QdpEngine
196419

197-
pytest.importorskip("torch")
198-
if not torch.cuda.is_available():
199-
pytest.skip("GPU required for QdpEngine")
200-
201420
engine = QdpEngine(device_id=0)
202421
num_qubits = 2 if data.ndim == 1 else 1
203422

testing/qdp_python/test_quantum_data_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
try:
2929
from qumat_qdp.loader import QuantumDataLoader
3030
except ImportError:
31-
QuantumDataLoader = None
31+
QuantumDataLoader: type[QuantumDataLoaderType] | None = None
3232

3333

3434
def _loader_available():

0 commit comments

Comments
 (0)