Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dev = [
"pytest-cov>=5.0",
"ruff>=0.6",
"ty>=0.0.1a1",
"hypothesis>=6.100",
"codespell>=2.3",
]

Expand Down
59 changes: 59 additions & 0 deletions tests/_hypothesis_strategies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Shared Hypothesis strategies for property-based tests across the suite.

The underlying compress/decompress/matmul/select kernels are
@numba.njit(cache=True); the first call in a process pays JIT compilation cost
that has nothing to do with the example being tested, so every property test
built on these strategies should also apply `_slow_first_call` (below) to
give Hypothesis room instead of tripping its default deadline/"too slow"
health check.
"""

from __future__ import annotations

import numpy as np
from hypothesis import HealthCheck, settings
from hypothesis import strategies as st
from hypothesis.extra.numpy import arrays

MAX_SIDE = 25

slow_first_call = settings(deadline=None, suppress_health_check=[HealthCheck.too_slow])


@st.composite
def dense_matrices(draw, *, max_side: int = MAX_SIDE, low: int = 0, high: int = 4) -> np.ndarray:
"""A dense float64 matrix with plenty of structural zeros, of a shrinkable
shape (including empty axes)."""
shape = (
draw(st.integers(0, max_side)),
draw(st.integers(0, max_side)),
)
values = draw(arrays(dtype=np.float64, shape=shape, elements=st.integers(low, high).map(float)))
zero_mask = draw(arrays(dtype=bool, shape=shape, elements=st.booleans()))
values[zero_mask] = 0.0
return values


def signed_dense_matrices(*, max_side: int = MAX_SIDE):
"""A dense_matrices() variant with negative values too, for max/min tests."""
return dense_matrices(max_side=max_side, low=-5, high=5)


def axis_key(data: st.DataObject, n: int):
"""One valid, possibly-duplicating/negative index selector for an axis of
length `n`: a full slice, a general slice, a fancy int list, or a boolean
mask. Never a bare int -- that collapses a dimension and is exercised by
dedicated unit tests instead."""
kind = data.draw(st.sampled_from(["full", "slice", "fancy", "bool"]))
if kind == "full":
return slice(None)
if kind == "slice":
start = data.draw(st.one_of(st.none(), st.integers(-n - 2, n + 2)))
stop = data.draw(st.one_of(st.none(), st.integers(-n - 2, n + 2)))
step = data.draw(st.sampled_from([None, 1, 2, 3, -1, -2]))
return slice(start, stop, step)
if kind == "fancy":
if n == 0:
return []
return data.draw(st.lists(st.integers(-n, n - 1), min_size=0, max_size=2 * n))
return data.draw(arrays(dtype=bool, shape=n, elements=st.booleans()))
72 changes: 8 additions & 64 deletions tests/test_general_indexing_nnz_astype.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
"""Tests for general (both-axes) indexing, getnnz/count_nonzero, and astype."""
"""Edge-case tests for both-axes indexing, plus getnnz/count_nonzero and astype.

General (row-key, col-key) indexing correctness against a dense reference is
covered by property-based tests in test_property_indexing.py; getnnz/
count_nonzero/astype don't vary with the index-shape space that targets, so
they stay as fixture-driven tests here.
"""

from __future__ import annotations

Expand All @@ -14,69 +20,7 @@ def vcls(request):
return request.param


# -- general (both-axes) indexing --------------------------------------------


def test_general_slice_both_axes_native(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
if dense.shape[0] < 2 or dense.shape[1] < 2:
pytest.skip("shape too small")
sub = v[0:2, 0:2]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.toarray(), dense[0:2, 0:2])


def test_slice_and_fancy_combo(dense, vcls):
if dense.shape[0] < 3 or dense.shape[1] < 3:
pytest.skip("shape too small")
v = vcls.from_scipy(sp.csr_array(dense))
sub = v[1:3, [0, 2]]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.toarray(), dense[1:3][:, [0, 2]])


def test_boolean_mask_both_axes(dense, vcls):
if dense.shape[0] < 2 or dense.shape[1] < 2:
pytest.skip("shape too small")
v = vcls.from_scipy(sp.csr_array(dense))
row_mask = np.zeros(dense.shape[0], dtype=bool)
row_mask[::2] = True
col_mask = np.zeros(dense.shape[1], dtype=bool)
col_mask[1::2] = True
sub = v[row_mask, :][:, col_mask]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.toarray(), dense[row_mask][:, col_mask])

sub2 = v[row_mask][:, col_mask]
np.testing.assert_allclose(sub2.toarray(), dense[row_mask][:, col_mask])


def test_minor_axis_only_selection(dense, vcls):
"""Selecting only along the minor axis (major key is a full slice) now stays native."""
v = vcls.from_scipy(sp.csr_array(dense))
n_minor = dense.shape[0] if vcls is VCSCArray else dense.shape[1]
if n_minor < 2:
pytest.skip("axis too small")
picks = [n_minor - 1, 0]
if vcls is VCSCArray:
sub, expected = v[picks, :], dense[picks, :]
else:
sub, expected = v[:, picks], dense[:, picks]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.toarray(), expected)


def test_minor_axis_boolean(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
n_minor = dense.shape[0] if vcls is VCSCArray else dense.shape[1]
mask = np.zeros(n_minor, dtype=bool)
mask[::2] = True
if vcls is VCSCArray:
sub, expected = v[mask, :], dense[mask, :]
else:
sub, expected = v[:, mask], dense[:, mask]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.toarray(), expected)
# -- both-axes indexing edge cases --------------------------------------------


def test_minor_axis_empty_selection(dense, vcls):
Expand Down
63 changes: 7 additions & 56 deletions tests/test_indexing.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""Edge-case and error-path tests for indexing on VCSCArray/VCSRArray.

General (row-key, col-key) indexing correctness against a dense reference --
including negative/fancy/boolean/duplicate keys -- is covered by property-based
tests in test_property_indexing.py.
"""

from __future__ import annotations

import numpy as np
Expand All @@ -16,68 +23,12 @@ def _as_dense(result):
return result.toarray() if hasattr(result, "toarray") else np.asarray(result)


def test_major_axis_slice(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
if vcls is VCSCArray:
sub, expected = v[:, 1:3], dense[:, 1:3]
else:
sub, expected = v[1:3, :], dense[1:3, :]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.to_scipy().toarray(), expected)


def test_major_axis_fancy_int(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
n = dense.shape[1] if vcls is VCSCArray else dense.shape[0]
if n < 2:
pytest.skip("axis too small")
picks = [n - 1, 0]
if vcls is VCSCArray:
sub, expected = v[:, picks], dense[:, picks]
else:
sub, expected = v[picks, :], dense[picks, :]
np.testing.assert_allclose(sub.to_scipy().toarray(), expected)


def test_major_axis_boolean(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
n = dense.shape[1] if vcls is VCSCArray else dense.shape[0]
mask = np.zeros(n, dtype=bool)
mask[::2] = True
if vcls is VCSCArray:
sub, expected = v[:, mask], dense[:, mask]
else:
sub, expected = v[mask, :], dense[mask, :]
np.testing.assert_allclose(sub.to_scipy().toarray(), expected)


def test_general_2d_indexing(dense, vcls):
"""Slicing both axes at once now stays VCS-native (see test_general_indexing_nnz_astype.py)."""
v = vcls.from_scipy(sp.csr_array(dense))
if dense.shape[0] < 2 or dense.shape[1] < 2:
pytest.skip("shape too small")
result = v[0:2, 0:2]
assert isinstance(result, vcls)
np.testing.assert_allclose(_as_dense(result), dense[0:2, 0:2])


def test_single_row_key_selects_rows(dense, vcls):
v = vcls.from_scipy(sp.csr_array(dense))
result = v[0]
np.testing.assert_allclose(_as_dense(result).reshape(-1), dense[0].reshape(-1))


def test_major_axis_negative_indexing(dense, vcls):
"""Verify that negative indexing on the major axis wraps around correctly."""
v = vcls.from_scipy(sp.csr_array(dense))
if vcls is VCSCArray:
sub, expected = v[:, -1], dense[:, [-1]]
else:
sub, expected = v[-1, :], dense[[-1], :]
assert isinstance(sub, vcls)
np.testing.assert_allclose(sub.to_scipy().toarray(), expected)


def test_major_axis_out_of_bounds_raises(dense, vcls):
"""Verify that out-of-bounds single or array integer indices raise IndexError."""
v = vcls.from_scipy(sp.csr_array(dense))
Expand Down
30 changes: 7 additions & 23 deletions tests/test_norm_selection.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
"""Tests for select() vs __getitem__() on normalized views, using a designed
two-population dataset (differing depth and marker genes) where the two
diverge substantially -- not a property that holds for arbitrary small random
matrices. General select()-matches-reference and select()-with-no-args
properties are covered in test_property_normalization.py instead.
"""

from __future__ import annotations

import numpy as np
Expand Down Expand Up @@ -95,29 +102,6 @@ def test_select_returns_a_view_that_still_composes(vcls):
np.testing.assert_allclose(sub @ B, _reference(dense[mask]) @ B, atol=1e-8)


def test_select_columns_and_both_axes(vcls):
"""Two index arrays select a sub-block, not a pointwise diagonal."""
dense, _ = _mixed_population()
nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized()
rows = np.arange(0, dense.shape[0], 7)
cols = np.arange(0, dense.shape[1], 5)
assert rows.shape != cols.shape

np.testing.assert_allclose(
nv.select(cols=cols).toarray(), _reference(dense[:, cols]), atol=1e-10
)
np.testing.assert_allclose(
nv.select(rows, cols).toarray(), _reference(dense[np.ix_(rows, cols)]), atol=1e-10
)


def test_select_everything_is_the_whole_view(vcls, dense):
if dense.sum() == 0:
pytest.skip("all-zero matrix: median row total is 0")
nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized()
np.testing.assert_allclose(nv.select().toarray(), nv.toarray(), atol=1e-12)


# -- __getitem__: a window that keeps the parent's statistics ----------------


Expand Down
78 changes: 6 additions & 72 deletions tests/test_ops.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"""Error-path and regression tests for scalar/matmul ops on VCSCArray/VCSRArray.

Numeric correctness against a dense reference (scalar mul/div, neg, transpose,
log1p, matvec/matmat) is covered by property-based tests in test_property_ops.py.
"""

from __future__ import annotations

import numpy as np
Expand All @@ -16,85 +22,13 @@ def _make(vcls, dense):
return vcls.from_scipy(sp.csr_array(dense))


def test_scalar_mul(dense, vcls):
v = _make(vcls, dense)
for scalar in (2.0, -1.5, 0.0):
out = v * scalar
np.testing.assert_allclose(out.to_scipy().toarray(), dense * scalar)
out2 = scalar * v
np.testing.assert_allclose(out2.to_scipy().toarray(), dense * scalar)


def test_scalar_div(dense, vcls):
v = _make(vcls, dense)
out = v / 2.0
np.testing.assert_allclose(out.to_scipy().toarray(), dense / 2.0)


def test_neg(dense, vcls):
v = _make(vcls, dense)
np.testing.assert_allclose((-v).to_scipy().toarray(), -dense)


def test_transpose(dense, vcls):
v = _make(vcls, dense)
vt = v.T
np.testing.assert_allclose(vt.to_scipy().toarray(), dense.T)
assert vt.shape == dense.T.shape
vtt = vt.T
assert type(vtt) is type(v)
np.testing.assert_allclose(vtt.to_scipy().toarray(), dense)


def test_log1p(dense, vcls):
v = _make(vcls, dense)
out = v.log1p()
np.testing.assert_allclose(out.to_scipy().toarray(), np.log1p(dense))


def test_matvec_right(dense, vcls, rng):
v = _make(vcls, dense)
x = rng.random(dense.shape[1])
np.testing.assert_allclose(v @ x, dense @ x, atol=1e-8)


def test_matvec_left(dense, vcls, rng):
v = _make(vcls, dense)
x = rng.random(dense.shape[0])
np.testing.assert_allclose(x @ v, x @ dense, atol=1e-8)


def test_matmat_right(dense, vcls, rng):
v = _make(vcls, dense)
b = rng.random((dense.shape[1], 3))
np.testing.assert_allclose(v @ b, dense @ b, atol=1e-8)


def test_matmat_left(dense, vcls, rng):
v = _make(vcls, dense)
b = rng.random((3, dense.shape[0]))
np.testing.assert_allclose(b @ v, b @ dense, atol=1e-8)


def test_matvec_dimension_mismatch_raises(dense, vcls):
"""Verify that dimension mismatch in matrix-vector product raises ValueError."""
v = _make(vcls, dense)
with pytest.raises(ValueError, match="not aligned"):
v @ np.ones(dense.shape[1] + 1)


def test_scalar_mul_zero_returns_empty_like(dense, vcls):
"""Verify that multiplying by 0 produces an empty-like array preserving shape and dtype."""
v = _make(vcls, dense)
v0 = v * 0
assert isinstance(v0, vcls)
assert v0.shape == v.shape
assert v0.dtype == v.dtype
assert v0.nnz == 0
assert v0.n_unique == 0
np.testing.assert_allclose(v0.toarray(), np.zeros(v.shape))


def test_unsupported_scalar_operands_raise(dense, vcls):
"""Non-scalar operands are now elementwise: non-broadcastable shapes/bad types raise, they don't silently no-op."""
v = _make(vcls, dense)
Expand Down
Loading