diff --git a/pyproject.toml b/pyproject.toml index b9863d1..4bcf7b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dev = [ "pytest-cov>=5.0", "ruff>=0.6", "ty>=0.0.1a1", + "hypothesis>=6.100", "codespell>=2.3", ] diff --git a/tests/_hypothesis_strategies.py b/tests/_hypothesis_strategies.py new file mode 100644 index 0000000..e00d89c --- /dev/null +++ b/tests/_hypothesis_strategies.py @@ -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())) diff --git a/tests/test_general_indexing_nnz_astype.py b/tests/test_general_indexing_nnz_astype.py index a263d52..b0fab8b 100644 --- a/tests/test_general_indexing_nnz_astype.py +++ b/tests/test_general_indexing_nnz_astype.py @@ -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 @@ -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): diff --git a/tests/test_indexing.py b/tests/test_indexing.py index fe29ad9..2f6fabd 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -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 @@ -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)) diff --git a/tests/test_norm_selection.py b/tests/test_norm_selection.py index bd3dbb3..bc4921f 100644 --- a/tests/test_norm_selection.py +++ b/tests/test_norm_selection.py @@ -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 @@ -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 ---------------- diff --git a/tests/test_ops.py b/tests/test_ops.py index cb21659..2df5d60 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -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 @@ -16,66 +22,6 @@ 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) @@ -83,18 +29,6 @@ def test_matvec_dimension_mismatch_raises(dense, vcls): 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) diff --git a/tests/test_property_indexing.py b/tests/test_property_indexing.py new file mode 100644 index 0000000..39e0485 --- /dev/null +++ b/tests/test_property_indexing.py @@ -0,0 +1,43 @@ +"""Property-based tests for general (row-key, col-key) indexing on +VCSCArray/VCSRArray, against dense reference indexing. + +Each axis key is independently one of: a full slice, a general slice, a fancy +int list (with duplicates and negative indices), or a boolean mask -- never a +bare int, since that collapses a dimension and is exercised by dedicated unit +tests in test_indexing.py / test_general_indexing_nnz_astype.py instead. This +generalizes the fixed hand-picked cases previously spread across +test_indexing.py, test_general_indexing_nnz_astype.py, and the manual +25-iteration fuzz loop in test_select_minor_fanout.py -- including the +duplicate-index "fan out" regression those covered. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import scipy.sparse as sp +from _hypothesis_strategies import axis_key, dense_matrices, slow_first_call +from hypothesis import given +from hypothesis import strategies as st + +from vsparse import VCSCArray, VCSRArray + + +def _scipy_for(vcls, dense): + return sp.csc_array(dense) if vcls is VCSCArray else sp.csr_array(dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), data=st.data()) +def test_general_indexing_matches_dense(vcls, dense, data): + row_key = axis_key(data, dense.shape[0]) + col_key = axis_key(data, dense.shape[1]) + + v = vcls.from_scipy(_scipy_for(vcls, dense)) + result = v[row_key, col_key] + expected = dense[row_key, :][:, col_key] + + assert isinstance(result, vcls) + assert result.shape == expected.shape + np.testing.assert_allclose(result.toarray(), expected) diff --git a/tests/test_property_normalization.py b/tests/test_property_normalization.py new file mode 100644 index 0000000..a7db114 --- /dev/null +++ b/tests/test_property_normalization.py @@ -0,0 +1,156 @@ +"""Property-based tests for VCSCArrayNormalized/VCSRArrayNormalized: the +default normalized() view, every RECIPES entry, and select(), against a plain +numpy reference implementation of the same math. + +Caching/staleness/identity contracts (recalculate=, weak-cache eviction, the +select()-vs-getitem() qualitative divergence on realistic data) aren't numeric +properties of arbitrary input and stay as designed-example tests in +test_vcs_norm.py, test_vcs_norm_recipes.py, and test_norm_selection.py. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import scipy.sparse as sp +from _hypothesis_strategies import axis_key, dense_matrices, slow_first_call +from hypothesis import assume, given +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays + +from vsparse import RECIPES, VCSCArray, VCSRArray + + +def _scipy_for(vcls, dense): + return sp.csc_array(dense) if vcls is VCSCArray else sp.csr_array(dense) + + +def _reference(dense: np.ndarray) -> np.ndarray: + """Read-depth normalize, log-transform, and mean-center a dense matrix directly.""" + row_totals = dense.sum(axis=1) + median = np.median(row_totals) if row_totals.shape[0] else 0.0 + if median > 0.0: + row_scale = row_totals / median + row_scale[row_scale == 0.0] = 1.0 + else: + # A non-positive median row total means depth normalization is skipped + # entirely (see _norm_common._compute_row_scale's `target <= 0.0` guard). + row_scale = np.ones_like(row_totals) + scaled = dense / row_scale[:, None] + gene_scale = scaled.sum(axis=0) + with np.errstate(divide="ignore", invalid="ignore"): + normalized = np.where(gene_scale > 0, scaled / gene_scale[None, :], 0.0) + transformed = np.log10(1.0 + 1000.0 * normalized) + return transformed - transformed.mean(axis=0, keepdims=True) + + +def _recipe_reference(dense: np.ndarray, recipe: str) -> np.ndarray: + """A plain-numpy version of ``y = (g(x * a * b) - c) * s`` for each recipe.""" + depth = dense.sum(axis=1) + with np.errstate(divide="ignore", invalid="ignore"): + if recipe == "raw": + a = np.ones_like(depth) + elif recipe in ("cp10k_log1p", "scanpy"): + a = np.where(depth > 0, 1e4 / depth, 1.0) + elif recipe == "parafac2": + median = np.median(depth) + a = np.where(depth > 0, median / depth, 1.0) if median > 0 else np.ones_like(depth) + elif recipe == "pearson": + a = np.where(depth > 0, 1.0 / depth, 1.0) + else: + raise ValueError(recipe) + + scaled = dense * a[:, None] + if recipe in ("parafac2", "pearson"): + gsum = scaled.sum(axis=0) + with np.errstate(divide="ignore", invalid="ignore"): + b = np.where(gsum > 0, 1.0 / gsum, 0.0) + else: + b = np.ones(dense.shape[1]) + + x = scaled * b[None, :] + if recipe in ("cp10k_log1p", "scanpy"): + g = np.log1p(x) + elif recipe == "parafac2": + g = np.log10(1.0 + 1000.0 * x) + elif recipe == "pearson": + g = np.sqrt(np.clip(x, 0.0, None)) + else: + g = x + + c = g.mean(axis=0) if recipe in ("parafac2", "scanpy", "pearson") else np.zeros(dense.shape[1]) + + if recipe in ("scanpy", "pearson"): + std = g.std(axis=0) + with np.errstate(divide="ignore", invalid="ignore"): + s = np.where(std > 0, 1.0 / std, 1.0) + else: + s = np.ones(dense.shape[1]) + + return (g - c) * s + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_default_normalized_matches_reference(vcls, dense): + assume(dense.sum() > 0) # median row total must be nonzero + v = vcls.from_scipy(_scipy_for(vcls, dense)) + nv = v.normalized() + np.testing.assert_allclose(nv.toarray(), _reference(dense), atol=1e-8) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), data=st.data()) +def test_default_normalized_matmul_matches_reference(vcls, dense, data): + assume(dense.sum() > 0) + v = vcls.from_scipy(_scipy_for(vcls, dense)) + nv = v.normalized() + ref = _reference(dense) + + p = data.draw(st.integers(0, 4)) + b = data.draw(arrays(dtype=np.float64, shape=(dense.shape[1], p), elements=st.floats(-10, 10))) + np.testing.assert_allclose(nv @ b, ref @ b, atol=1e-6) + + q = data.draw(st.integers(0, 4)) + c = data.draw(arrays(dtype=np.float64, shape=(q, dense.shape[0]), elements=st.floats(-10, 10))) + np.testing.assert_allclose(c @ nv, c @ ref, atol=1e-6) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@pytest.mark.parametrize("recipe", sorted(RECIPES)) +@slow_first_call +@given(dense=dense_matrices()) +def test_recipe_matches_reference(vcls, recipe, dense): + assume(dense.sum() > 0) + v = vcls.from_scipy(_scipy_for(vcls, dense)) + nv = v.normalized(recipe) + assert nv.recipe.name == recipe + np.testing.assert_allclose(nv.toarray(), _recipe_reference(dense, recipe), atol=1e-5) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_select_with_no_args_is_the_whole_view(vcls, dense): + assume(dense.sum() > 0) + nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized() + np.testing.assert_allclose(nv.select().toarray(), nv.toarray(), atol=1e-8) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), data=st.data()) +def test_select_matches_reference_on_the_selection(vcls, dense, data): + """select(rows, cols) recomputes statistics for that sub-block, so it must + agree with normalizing the dense sub-block directly.""" + assume(dense.sum() > 0) + rows = axis_key(data, dense.shape[0]) + cols = axis_key(data, dense.shape[1]) + sub_dense = dense[rows, :][:, cols] + assume(sub_dense.sum() > 0) + + nv = vcls.from_scipy(_scipy_for(vcls, dense)).normalized() + got = nv.select(rows, cols).toarray() + np.testing.assert_allclose(got, _reference(sub_dense), atol=1e-6) diff --git a/tests/test_property_ops.py b/tests/test_property_ops.py new file mode 100644 index 0000000..660f6cf --- /dev/null +++ b/tests/test_property_ops.py @@ -0,0 +1,100 @@ +"""Property-based tests for elementwise scalar ops, transpose, log1p, and +matvec/matmat on VCSCArray/VCSRArray, against a dense reference.""" + +from __future__ import annotations + +import numpy as np +import pytest +import scipy.sparse as sp +from _hypothesis_strategies import dense_matrices, slow_first_call +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays + +from vsparse import VCSCArray, VCSRArray + + +def _make(vcls, dense): + return vcls.from_scipy(sp.csr_array(dense)) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), scalar=st.sampled_from([2.0, -1.5, 0.0])) +def test_scalar_mul(vcls, dense, scalar): + v = _make(vcls, dense) + np.testing.assert_allclose((v * scalar).toarray(), dense * scalar) + np.testing.assert_allclose((scalar * v).toarray(), dense * scalar) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_scalar_div(vcls, dense): + v = _make(vcls, dense) + np.testing.assert_allclose((v / 2.0).toarray(), dense / 2.0) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_neg(vcls, dense): + v = _make(vcls, dense) + np.testing.assert_allclose((-v).toarray(), -dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_transpose(vcls, dense): + v = _make(vcls, dense) + vt = v.T + np.testing.assert_allclose(vt.toarray(), dense.T) + assert vt.shape == dense.T.shape + vtt = vt.T + assert type(vtt) is type(v) + np.testing.assert_allclose(vtt.toarray(), dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_log1p(vcls, dense): + v = _make(vcls, dense) + np.testing.assert_allclose(v.log1p().toarray(), np.log1p(dense)) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), data=st.data()) +def test_matvec_and_matmat(vcls, dense, data): + v = _make(vcls, dense) + n_rows, n_cols = dense.shape + p = data.draw(st.integers(0, 4)) + + x = data.draw(arrays(dtype=np.float64, shape=n_cols, elements=st.floats(-10, 10))) + np.testing.assert_allclose(v @ x, dense @ x, atol=1e-8) + + y = data.draw(arrays(dtype=np.float64, shape=n_rows, elements=st.floats(-10, 10))) + np.testing.assert_allclose(y @ v, y @ dense, atol=1e-8) + + b = data.draw(arrays(dtype=np.float64, shape=(n_cols, p), elements=st.floats(-10, 10))) + np.testing.assert_allclose(v @ b, dense @ b, atol=1e-8) + + c = data.draw(arrays(dtype=np.float64, shape=(p, n_rows), elements=st.floats(-10, 10))) + np.testing.assert_allclose(c @ v, c @ dense, atol=1e-8) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_scalar_mul_zero_returns_empty_like(vcls, dense): + """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)) diff --git a/tests/test_property_reductions_arith.py b/tests/test_property_reductions_arith.py new file mode 100644 index 0000000..6b8e82f --- /dev/null +++ b/tests/test_property_reductions_arith.py @@ -0,0 +1,97 @@ +"""Property-based tests for per-axis sum/mean/max/min and elementwise +arithmetic on VCSCArray/VCSRArray, against a dense reference.""" + +from __future__ import annotations + +import numpy as np +import pytest +import scipy.sparse as sp +from _hypothesis_strategies import dense_matrices, signed_dense_matrices, slow_first_call +from hypothesis import given + +from vsparse import VCSCArray, VCSRArray + + +def _make(vcls, dense): + return vcls.from_scipy(sp.csr_array(dense)) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_sum_and_mean_per_axis(vcls, dense): + v = _make(vcls, dense) + assert v.sum() == pytest.approx(dense.sum()) + np.testing.assert_allclose(v.sum(axis=0), dense.sum(axis=0)) + np.testing.assert_allclose(v.sum(axis=1), dense.sum(axis=1)) + if dense.size: + assert v.mean() == pytest.approx(dense.mean()) + np.testing.assert_allclose(v.mean(axis=0), dense.mean(axis=0)) + np.testing.assert_allclose(v.mean(axis=1), dense.mean(axis=1)) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=signed_dense_matrices()) +def test_max_and_min_per_axis(vcls, dense): + """Extrema have to fold in the implicit zeros the layout never stores.""" + if dense.size == 0: + return + v = _make(vcls, dense) + assert v.max() == pytest.approx(dense.max()) + assert v.min() == pytest.approx(dense.min()) + np.testing.assert_allclose(v.max(axis=0), dense.max(axis=0)) + np.testing.assert_allclose(v.max(axis=1), dense.max(axis=1)) + np.testing.assert_allclose(v.min(axis=0), dense.min(axis=0)) + np.testing.assert_allclose(v.min(axis=1), dense.min(axis=1)) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_add_sub_vcs_vcs(vcls, dense): + v = _make(vcls, dense) + other_dense = dense * 2 + other = _make(vcls, other_dense) + + added = v + other + assert isinstance(added, vcls) + np.testing.assert_allclose(added.toarray(), dense + other_dense) + + subbed = v - other + assert isinstance(subbed, vcls) + np.testing.assert_allclose(subbed.toarray(), dense - other_dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_add_sub_dense(vcls, dense): + v = _make(vcls, dense) + other_dense = np.ones_like(dense) + + np.testing.assert_allclose(np.asarray(v + other_dense), dense + other_dense) + np.testing.assert_allclose(np.asarray(other_dense + v), other_dense + dense) + np.testing.assert_allclose(np.asarray(v - other_dense), dense - other_dense) + np.testing.assert_allclose(np.asarray(other_dense - v), other_dense - dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_multiply_elementwise(vcls, dense): + v = _make(vcls, dense) + other_dense = dense + 1 # avoid trivially all-zero result + other = _make(vcls, other_dense) + + prod = v.multiply(other) + assert isinstance(prod, vcls) + np.testing.assert_allclose(prod.toarray(), dense * other_dense) + + prod_star = v * other + assert isinstance(prod_star, vcls) + np.testing.assert_allclose(prod_star.toarray(), dense * other_dense) + + prod_dense = v.multiply(other_dense) + assert isinstance(prod_dense, vcls) + np.testing.assert_allclose(prod_dense.toarray(), dense * other_dense) diff --git a/tests/test_property_roundtrip.py b/tests/test_property_roundtrip.py new file mode 100644 index 0000000..cbb36f4 --- /dev/null +++ b/tests/test_property_roundtrip.py @@ -0,0 +1,65 @@ +"""Property-based tests via Hypothesis for VCSC/VCSR construction and round-trip. + +These cover the same kind of ground as the manual `rng.integers(...)` fuzzing +used elsewhere (e.g. test_chunked_transpose.py), but let Hypothesis choose +shapes/values/densities and shrink any failure to a minimal example instead of +us hand-picking a handful of cases. +""" + +from __future__ import annotations + +import numpy as np +import pytest +import scipy.sparse as sp +from _hypothesis_strategies import dense_matrices, slow_first_call +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.extra.numpy import arrays + +from vsparse import VCSCArray, VCSRArray + + +def _scipy_for(vcls, dense: np.ndarray): + return sp.csc_array(dense) if vcls is VCSCArray else sp.csr_array(dense) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_from_scipy_toarray_roundtrips(vcls, dense): + """Compressing and decompressing must reproduce the original matrix exactly, + for any shape/density/zero-pattern -- not just the handful conftest picks.""" + v = vcls.from_scipy(_scipy_for(vcls, dense)) + + np.testing.assert_array_equal(v.toarray(), dense) + assert v.shape == dense.shape + assert v.nnz == int(np.count_nonzero(dense)) + np.testing.assert_array_equal(v.to_scipy().toarray(), dense) + other = VCSRArray if vcls is VCSCArray else VCSCArray + np.testing.assert_array_equal( + (v.to_csr() if other is VCSCArray else v.to_csc()).toarray(), dense + ) + + +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices(), data=st.data()) +def test_matmul_matches_dense_reference(vcls, dense, data): + """v @ B must agree with the dense reference for any compatible B, at any + shape Hypothesis manages to construct (including empty axes).""" + n_cols = dense.shape[1] + p = data.draw(st.integers(0, 4)) + b = data.draw(arrays(dtype=np.float64, shape=(n_cols, p), elements=st.floats(-10, 10))) + + v = vcls.from_scipy(_scipy_for(vcls, dense)) + np.testing.assert_allclose(v @ b, dense @ b, atol=1e-8) + + +def test_value_compression_deduplicates(): + """Repeated values within a major slice collapse to one stored entry.""" + dense = np.zeros((10, 10)) + dense[:, 0] = 3.0 # ten repeats of the same value in one column + dense[0, 1] = 7.0 + v = VCSCArray.from_scipy(sp.csc_array(dense)) + assert v.nnz == 11 + assert v.n_unique == 2 # one unique value per nonempty column diff --git a/tests/test_construct.py b/tests/test_property_transpose.py similarity index 51% rename from tests/test_construct.py rename to tests/test_property_transpose.py index 7155467..23c9b72 100644 --- a/tests/test_construct.py +++ b/tests/test_property_transpose.py @@ -1,24 +1,26 @@ -"""Tests for vsparse._construct.transpose_major: direct VCSC<->VCSR storage regrouping.""" +"""Property-based tests for vsparse._construct.transpose_major: direct +VCSC<->VCSR storage regrouping, via _VCSBase._transpose_major().""" from __future__ import annotations import numpy as np import pytest import scipy.sparse as sp +from _hypothesis_strategies import dense_matrices, slow_first_call +from hypothesis import example, given from vsparse import VCSCArray, VCSRArray -@pytest.fixture(params=[VCSCArray, VCSRArray]) -def vcls(request): - return request.param - - def _scipy_for(vcls, dense): return sp.csc_array(dense) if vcls is VCSCArray else sp.csr_array(dense) -def test_transpose_major_matches_dense(dense, vcls): +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@example(dense=np.zeros((6, 5))) +@given(dense=dense_matrices()) +def test_transpose_major_matches_dense(vcls, dense): v = vcls.from_scipy(_scipy_for(vcls, dense)) dual = v._transpose_major() @@ -26,24 +28,14 @@ def test_transpose_major_matches_dense(dense, vcls): assert isinstance(dual, other_cls) assert dual.shape == dense.shape np.testing.assert_allclose(dual.toarray(), dense) - - -def test_transpose_major_preserves_value_dedup(dense, vcls): - """Same logical nonzeros, so nnz must match; unique-value counts may differ per axis.""" - v = vcls.from_scipy(_scipy_for(vcls, dense)) - dual = v._transpose_major() + # Same logical nonzeros, so nnz must match; unique-value counts may differ per axis. assert dual.nnz == v.nnz -def test_transpose_major_all_zero(vcls): - dense = np.zeros((6, 5)) - v = vcls.from_scipy(_scipy_for(vcls, dense)) - dual = v._transpose_major() - assert dual.nnz == 0 - np.testing.assert_allclose(dual.toarray(), dense) - - -def test_transpose_major_is_involutive(dense, vcls): +@pytest.mark.parametrize("vcls", [VCSCArray, VCSRArray]) +@slow_first_call +@given(dense=dense_matrices()) +def test_transpose_major_is_involutive(vcls, dense): """Transposing twice returns to the original format with the same matrix.""" v = vcls.from_scipy(_scipy_for(vcls, dense)) back = v._transpose_major()._transpose_major() diff --git a/tests/test_reductions_and_arith.py b/tests/test_reductions_and_arith.py index d1fe920..6d4cf72 100644 --- a/tests/test_reductions_and_arith.py +++ b/tests/test_reductions_and_arith.py @@ -1,4 +1,9 @@ -"""Tests for per-axis sum/mean/max/min and elementwise arithmetic on VCSCArray/VCSRArray.""" +"""Edge-case and error-path tests for per-axis sum/mean/max/min and elementwise +arithmetic on VCSCArray/VCSRArray. + +General numeric correctness against a dense reference is covered by +property-based tests in test_property_reductions_arith.py. +""" from __future__ import annotations @@ -14,64 +19,12 @@ def vcls(request): return request.param -def make_signed_dense(rng: np.random.Generator, shape: tuple[int, int]) -> np.ndarray: - """Dense matrix with negative, positive, and structural-zero entries.""" - dense = rng.integers(-5, 6, size=shape).astype(np.float64) - mask = rng.random(shape) < 0.4 - dense[mask] = 0.0 - return dense - - -@pytest.fixture(params=[(1, 1), (5, 1), (1, 7), (8, 6), (25, 40), (50, 3)]) -def shape(request) -> tuple[int, int]: - return request.param - - -@pytest.fixture -def signed_dense(shape) -> np.ndarray: - return make_signed_dense(np.random.default_rng(7), shape) - - -# -- sum / mean ------------------------------------------------------------- - - -def test_sum_per_axis(dense, vcls): - v = vcls.from_scipy(sp.csr_array(dense)) - assert v.sum() == pytest.approx(dense.sum()) - np.testing.assert_allclose(v.sum(axis=0), dense.sum(axis=0)) - np.testing.assert_allclose(v.sum(axis=1), dense.sum(axis=1)) - - def test_sum_invalid_axis(dense, vcls): v = vcls.from_scipy(sp.csr_array(dense)) with pytest.raises(ValueError): v.sum(axis=2) -def test_mean_per_axis(dense, vcls): - v = vcls.from_scipy(sp.csr_array(dense)) - assert v.mean() == pytest.approx(dense.mean()) - np.testing.assert_allclose(v.mean(axis=0), dense.mean(axis=0)) - np.testing.assert_allclose(v.mean(axis=1), dense.mean(axis=1)) - - -# -- max / min ---------------------------------------------------------------- - - -def test_max_per_axis(signed_dense, vcls): - v = vcls.from_scipy(sp.csr_array(signed_dense)) - assert v.max() == pytest.approx(signed_dense.max()) - np.testing.assert_allclose(v.max(axis=0), signed_dense.max(axis=0)) - np.testing.assert_allclose(v.max(axis=1), signed_dense.max(axis=1)) - - -def test_min_per_axis(signed_dense, vcls): - v = vcls.from_scipy(sp.csr_array(signed_dense)) - assert v.min() == pytest.approx(signed_dense.min()) - np.testing.assert_allclose(v.min(axis=0), signed_dense.min(axis=0)) - np.testing.assert_allclose(v.min(axis=1), signed_dense.min(axis=1)) - - def test_max_min_invalid_axis(dense, vcls): v = vcls.from_scipy(sp.csr_array(dense)) with pytest.raises(ValueError): @@ -99,40 +52,6 @@ def test_max_min_fully_dense_negative(): np.testing.assert_allclose(v.max(axis=1), dense.max(axis=1)) -# -- elementwise arithmetic --------------------------------------------------- - - -def test_add_sub_vcs_vcs(dense, vcls): - v = vcls.from_scipy(sp.csr_array(dense)) - other_dense = dense * 2 - other = vcls.from_scipy(sp.csr_array(other_dense)) - - added = v + other - assert isinstance(added, vcls) - np.testing.assert_allclose(added.toarray(), dense + other_dense) - - subbed = v - other - assert isinstance(subbed, vcls) - np.testing.assert_allclose(subbed.toarray(), dense - other_dense) - - -def test_add_sub_dense(dense, vcls): - v = vcls.from_scipy(sp.csr_array(dense)) - other_dense = np.ones_like(dense) - - added = v + other_dense - np.testing.assert_allclose(np.asarray(added), dense + other_dense) - - radded = other_dense + v - np.testing.assert_allclose(np.asarray(radded), other_dense + dense) - - subbed = v - other_dense - np.testing.assert_allclose(np.asarray(subbed), dense - other_dense) - - rsubbed = other_dense - v - np.testing.assert_allclose(np.asarray(rsubbed), other_dense - dense) - - def test_add_sub_zero_scalar(dense, vcls): v = vcls.from_scipy(sp.csr_array(dense)) np.testing.assert_allclose((v + 0).toarray(), dense) @@ -148,32 +67,3 @@ def test_add_nonzero_scalar_raises(dense, vcls): v - 5 with pytest.raises(NotImplementedError): 5 - v - - -def test_multiply_elementwise(dense, vcls): - v = vcls.from_scipy(sp.csr_array(dense)) - other_dense = dense + 1 # avoid trivially all-zero result - other = vcls.from_scipy(sp.csr_array(other_dense)) - - prod = v.multiply(other) - assert isinstance(prod, vcls) - np.testing.assert_allclose(prod.toarray(), dense * other_dense) - - prod_star = v * other - assert isinstance(prod_star, vcls) - np.testing.assert_allclose(prod_star.toarray(), dense * other_dense) - - prod_dense = v.multiply(other_dense) - assert isinstance(prod_dense, vcls) - np.testing.assert_allclose(prod_dense.toarray(), dense * other_dense) - - -def test_scalar_mul_div_unaffected(dense, vcls): - """Existing scalar multiply/divide behavior must be preserved.""" - v = vcls.from_scipy(sp.csr_array(dense)) - np.testing.assert_allclose((v * 3).toarray(), dense * 3) - np.testing.assert_allclose((3 * v).toarray(), dense * 3) - with np.errstate(invalid="ignore", divide="ignore"): - np.testing.assert_allclose((v / 2).toarray(), dense / 2) - assert isinstance(v * 0, vcls) - np.testing.assert_allclose((v * 0).toarray(), np.zeros_like(dense)) diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py deleted file mode 100644 index 33a7fb9..0000000 --- a/tests/test_roundtrip.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import numpy as np - -from vsparse import VCSCArray, VCSRArray - - -def test_csc_roundtrip(dense, csc): - v = VCSCArray.from_scipy(csc) - back = v.to_scipy() - assert back.format == "csc" - assert back.shape == csc.shape - np.testing.assert_allclose(back.toarray(), dense) - np.testing.assert_allclose(v.to_csr().toarray(), dense) - assert v.nnz == csc.nnz - - -def test_csr_roundtrip(dense, csr): - v = VCSRArray.from_scipy(csr) - back = v.to_scipy() - assert back.format == "csr" - assert back.shape == csr.shape - np.testing.assert_allclose(back.toarray(), dense) - np.testing.assert_allclose(v.to_csc().toarray(), dense) - assert v.nnz == csr.nnz - - -def test_csc_from_csr_input(dense, csr): - v = VCSCArray.from_scipy(csr) - np.testing.assert_allclose(v.to_scipy().toarray(), dense) - - -def test_value_compression_deduplicates(rng): - dense = np.zeros((10, 10)) - dense[:, 0] = 3.0 # ten repeats of the same value in one column - dense[0, 1] = 7.0 - v = VCSCArray.from_scipy(__import__("scipy.sparse", fromlist=["csc_array"]).csc_array(dense)) - assert v.nnz == 11 - assert v.n_unique == 2 # one unique value per nonempty column - - -def test_empty_matrix_roundtrip(): - import scipy.sparse as sp - - dense = np.zeros((6, 4)) - v = VCSCArray.from_scipy(sp.csc_array(dense)) - assert v.nnz == 0 - np.testing.assert_allclose(v.to_scipy().toarray(), dense) diff --git a/tests/test_select_minor_fanout.py b/tests/test_select_minor_fanout.py index 5af4cd9..19eb3d5 100644 --- a/tests/test_select_minor_fanout.py +++ b/tests/test_select_minor_fanout.py @@ -1,3 +1,10 @@ +"""Regression and memory tests for duplicate-minor-index selection. + +General duplicate-index fanout, matched against a dense/scipy reference across +arbitrary shapes and index lists, is covered by property-based tests in +test_property_indexing.py. +""" + from __future__ import annotations import tracemalloc diff --git a/tests/test_vcs_norm.py b/tests/test_vcs_norm.py index 700d660..5da4f6c 100644 --- a/tests/test_vcs_norm.py +++ b/tests/test_vcs_norm.py @@ -1,4 +1,8 @@ -"""Tests for VCSCArrayNormalized/VCSRArrayNormalized: normalized VCSC/VCSR views.""" +"""Tests for VCSCArrayNormalized/VCSRArrayNormalized: normalized VCSC/VCSR views. + +Numeric correctness of the default view against a dense reference (toarray, +matmul) is covered by property-based tests in test_property_normalization.py. +""" from __future__ import annotations @@ -35,15 +39,6 @@ def _reference(dense: np.ndarray) -> np.ndarray: return transformed - transformed.mean(axis=0, keepdims=True) -def test_toarray_matches_reference(dense, vcls): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized() - assert isinstance(nv, _norm_cls(vcls)) - np.testing.assert_allclose(nv.toarray(), _reference(dense), atol=1e-8) - - def test_getitem_matches_reference_block(dense, vcls): if dense.sum() == 0 or dense.shape[0] < 2 or dense.shape[1] < 2: pytest.skip("shape too small or all-zero") @@ -107,55 +102,7 @@ def test_unsupported_operations_raise_runtime_error(dense, vcls, op): op(nv) -# -- matmul: nv @ B / B @ nv, against a dense reference ---------------------- - - -def test_matmul_matches_reference(dense, vcls): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized() - ref = _reference(dense) - - rng = np.random.default_rng(7) - B = rng.normal(size=(dense.shape[1], 3)) - np.testing.assert_allclose(nv @ B, ref @ B, atol=1e-7) - - -def test_matvec_matches_reference(dense, vcls): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized() - ref = _reference(dense) - - rng = np.random.default_rng(8) - b = rng.normal(size=dense.shape[1]) - np.testing.assert_allclose(nv @ b, ref @ b, atol=1e-7) - - -def test_rmatmul_matches_reference(dense, vcls): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized() - ref = _reference(dense) - - rng = np.random.default_rng(9) - B = rng.normal(size=(3, dense.shape[0])) - np.testing.assert_allclose(B @ nv, B @ ref, atol=1e-7) - - -def test_rmatvec_matches_reference(dense, vcls): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized() - ref = _reference(dense) - - rng = np.random.default_rng(10) - b = rng.normal(size=dense.shape[0]) - np.testing.assert_allclose(b @ nv, b @ ref, atol=1e-7) +# -- matmul: error paths (numeric correctness is property-tested) ------------ def test_matmul_bad_shape_raises(dense, vcls): diff --git a/tests/test_vcs_norm_recipes.py b/tests/test_vcs_norm_recipes.py index 9dad195..4d5a7e0 100644 --- a/tests/test_vcs_norm_recipes.py +++ b/tests/test_vcs_norm_recipes.py @@ -1,4 +1,8 @@ -"""Tests for normalization recipes (issue #40): multiple views, caching, and staleness.""" +"""Tests for normalization recipes (issue #40): multiple views, caching, and staleness. + +Per-recipe numeric correctness against a dense reference is covered by +property-based tests in test_property_normalization.py. +""" from __future__ import annotations @@ -10,15 +14,7 @@ import pytest import scipy.sparse as sp -from vsparse import ( - RECIPES, - Recipe, - VCSCAnnData, - VCSCArray, - VCSCArrayNormalized, - VCSRArray, - VCSRArrayNormalized, -) +from vsparse import RECIPES, Recipe, VCSCAnnData, VCSCArray, VCSRArray from vsparse._norm_common import NORM_CACHE_MAXSIZE @@ -31,10 +27,6 @@ def _scipy_for(vcls, dense): return sp.csc_array(dense) if vcls is VCSCArray else sp.csr_array(dense) -def _norm_cls(vcls): - return VCSCArrayNormalized if vcls is VCSCArray else VCSRArrayNormalized - - def _reference(dense: np.ndarray, recipe: str) -> np.ndarray: """A plain-numpy version of ``y = (g(x * a * b) - c) * s`` for each recipe.""" depth = dense.sum(axis=1) @@ -82,33 +74,9 @@ def _reference(dense: np.ndarray, recipe: str) -> np.ndarray: # -- per-recipe numerical correctness ----------------------------------------- - - -@pytest.mark.parametrize("recipe", sorted(RECIPES)) -def test_toarray_matches_reference_for_every_recipe(dense, vcls, recipe): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized(recipe) - assert isinstance(nv, _norm_cls(vcls)) - assert nv.recipe.name == recipe - np.testing.assert_allclose(nv.toarray(), _reference(dense, recipe), atol=1e-6) - - -@pytest.mark.parametrize("recipe", sorted(RECIPES)) -def test_matmul_matches_reference_for_every_recipe(dense, vcls, recipe): - if dense.sum() == 0: - pytest.skip("all-zero matrix: median row total is 0") - v = vcls.from_scipy(_scipy_for(vcls, dense)) - nv = v.normalized(recipe) - ref = _reference(dense, recipe) - - rng = np.random.default_rng(11) - B = rng.normal(size=(dense.shape[1], 3)) - np.testing.assert_allclose(nv @ B, ref @ B, atol=1e-5) - - Bl = rng.normal(size=(3, dense.shape[0])) - np.testing.assert_allclose(Bl @ nv, Bl @ ref, atol=1e-5) +# +# Covered by property-based tests in test_property_normalization.py +# (test_recipe_matches_reference), across arbitrary shapes/densities. def test_unknown_view_raises(vcls, dense): diff --git a/uv.lock b/uv.lock index 31295e5..06e08d6 100644 --- a/uv.lock +++ b/uv.lock @@ -470,6 +470,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/3e/b3a66a07d99b52cfaf9d64ccaf7667ce7d75ec733d6c0ab6755eaa3944a1/hdf5plugin-7.1.0-py3-none-win_amd64.whl", hash = "sha256:fb4555696340a0dceb16f48ae5b65479f6a92ca90190ffeafc41905f17f5e325", size = 3682141, upload-time = "2026-09-01T14:39:02.131Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ce/c0946bebffb99b62426a6a7643d4272cc6c5cf777a488b3b4d0ee724e960/hypothesis-6.168.0.tar.gz", hash = "sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e", size = 510805, upload-time = "2026-09-08T18:48:36.072Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/f8/8b2cc9ae7b439538f6f2d32a92892340b6343a6b04d171c516666901dedc/hypothesis-6.168.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2", size = 791358, upload-time = "2026-09-08T18:47:37.076Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f4/4d7d897310cde5085779fb96feadb8529d98cb8e51ed7b24f7da9b6c6bdc/hypothesis-6.168.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099", size = 787081, upload-time = "2026-09-08T18:47:16.227Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/9d52066d363faba7f3ac20ee60a3c696a475feb2c477d235d0d649d41cc1/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4", size = 1123850, upload-time = "2026-09-08T18:48:11.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/cf/aa46d76fa7df43caf2c372e394fda84ce1dc08421814f8674a6b9295e2ea/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f", size = 1147685, upload-time = "2026-09-08T18:46:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/78ed8c8d5aa37e4baae2a4b3e29687ee3d9b7f1a5e56ea5ea5ec7ec71ecb/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1", size = 1149294, upload-time = "2026-09-08T18:47:04.757Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/d57440f19e9de70e85359cf179ce786f309ee17609bd3c5a0113272875c0/hypothesis-6.168.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213", size = 1169729, upload-time = "2026-09-08T18:47:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/a1/86/dc74410a186990bb22c2a3eea0e77804f2eb0f300860b46d7d8948073674/hypothesis-6.168.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131", size = 1129182, upload-time = "2026-09-08T18:46:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5e/be048fc4f6dac831625e155bdf11e4233caf46bb54030391d6fba8e19449/hypothesis-6.168.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149", size = 1160180, upload-time = "2026-09-08T18:47:26.459Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4a/15a34498a5f08720fbbdbbe4668a8050fe4e17c16c9eeb6f56a017f2fa6b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d", size = 1299711, upload-time = "2026-09-08T18:47:28.34Z" }, + { url = "https://files.pythonhosted.org/packages/77/09/5354e0dae302ab98c4f0046b7e8699c2186ae4349397bda5d5c852bda68b/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517", size = 1425341, upload-time = "2026-09-08T18:46:56.785Z" }, + { url = "https://files.pythonhosted.org/packages/59/7c/3c0e1f59043ff128c70a51d298d3d6b5973525c357a1e1d0542dbc05ac90/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349", size = 1281063, upload-time = "2026-09-08T18:46:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/db884db9a7d42ae6b72a00c13c725940b638dfb263e618b22af59d5dfa2f/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b", size = 1300247, upload-time = "2026-09-08T18:47:02.949Z" }, + { url = "https://files.pythonhosted.org/packages/99/8a/4ee9769e1d48676efb6a78a130f82e0d52d3f87b2055294102272a08615c/hypothesis-6.168.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377", size = 1336084, upload-time = "2026-09-08T18:48:05.675Z" }, + { url = "https://files.pythonhosted.org/packages/61/17/d4ed11bc99d205d6d2651a0f1a4874f377150f836b5a0849bd511d68a2eb/hypothesis-6.168.0-cp310-abi3-win32.whl", hash = "sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29", size = 677989, upload-time = "2026-09-08T18:47:24.722Z" }, + { url = "https://files.pythonhosted.org/packages/77/51/abf1fde7b8afab87db30afb73b3847e62440551d146472111cabeba2fe00/hypothesis-6.168.0-cp310-abi3-win_amd64.whl", hash = "sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b", size = 684692, upload-time = "2026-09-08T18:46:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/12/e2/64d79aed47a95186ce9edcef60eb4684870c72542da3b7027c2483d8ee8c/hypothesis-6.168.0-cp310-abi3-win_arm64.whl", hash = "sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc", size = 682709, upload-time = "2026-09-08T18:48:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5e/0035896c101f0484c364353f8ee30175eef8936b49171618677287fdd85d/hypothesis-6.168.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda", size = 793115, upload-time = "2026-09-08T18:48:21.352Z" }, + { url = "https://files.pythonhosted.org/packages/11/5c/938173e27df771cc6e92bc47f117a1b1be4a88fc6dc214f7fc65f9c7ad93/hypothesis-6.168.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79", size = 784634, upload-time = "2026-09-08T18:47:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/0b2c6ac07d519131f97712f2533750ffe9b2490eec8f518ef3a5ed2dd514/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7", size = 1122855, upload-time = "2026-09-08T18:46:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/dde930fe43171cab37572bd993d70c2a271f240f428f8ccd64ec4c2d661b/hypothesis-6.168.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4", size = 1168932, upload-time = "2026-09-08T18:46:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/4c/5d/92b83c3d06194ec626e92723d0b0f70221ebf42d7cb355ed36929df6d735/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d", size = 1298566, upload-time = "2026-09-08T18:48:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/9c/67/52de8bf3446e3d2d555b812d96673b31bb213b5b9d5804a666e5d0bba76e/hypothesis-6.168.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d", size = 1335074, upload-time = "2026-09-08T18:48:01.561Z" }, + { url = "https://files.pythonhosted.org/packages/14/fd/e592773c1c0ce55e35d26ec55f75546bf1fd72ef5a5c520ed685969b40cb/hypothesis-6.168.0-cp312-cp312-win_amd64.whl", hash = "sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68", size = 682026, upload-time = "2026-09-08T18:47:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/39bb8fcccfbafd10fcc777d583c6ebad5d2148e7d743cb350562f28e974f/hypothesis-6.168.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28", size = 793050, upload-time = "2026-09-08T18:47:56.045Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dd/00fd32e8ec470535e0065cb8d6e175f9fc54b4d3a6f1269f6c487f8bd79d/hypothesis-6.168.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20", size = 784558, upload-time = "2026-09-08T18:46:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4d/553c47093f68bdbac0438e16c024ce97649b5804dc6972ef86b9bd2db8a1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475", size = 1122841, upload-time = "2026-09-08T18:48:09.374Z" }, + { url = "https://files.pythonhosted.org/packages/43/d6/0b5940aa75e617c8fd12200bae24d1b71347362514e8210735c581d4d3d1/hypothesis-6.168.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0", size = 1168825, upload-time = "2026-09-08T18:47:33.702Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/800de1231b2869b51409bbf85799d6f1bf49a00e0afff0aabc097aa8f1b7/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669", size = 1298433, upload-time = "2026-09-08T18:46:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/35/9907667a30c1dbabbc44a09b4c25a0f575570937fcbbada924ae3a1dbf2a/hypothesis-6.168.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c", size = 1334988, upload-time = "2026-09-08T18:48:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/7bf214e703fff532ed47cb52ab93ce4b7ea41e4e7084593fa02b740828b7/hypothesis-6.168.0-cp313-cp313-win_amd64.whl", hash = "sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d", size = 681990, upload-time = "2026-09-08T18:47:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c1/64b36b250b1f66abb6ce8c81775d3373d149bc89cbea477ed71b57cf7d1b/hypothesis-6.168.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc", size = 793101, upload-time = "2026-09-08T18:46:54.067Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c4/494e42304b15f4ec649d36bbc3fc01cef1b63405cf30d4087ae07d048172/hypothesis-6.168.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8", size = 784632, upload-time = "2026-09-08T18:48:31.551Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/90d874cb1d749f505749c9908f34e803b97b03457797d5893354980558bc/hypothesis-6.168.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93", size = 1123101, upload-time = "2026-09-08T18:48:17.362Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/ec893d0e5f4bcdd0121a8a4280f4e8aba3b4cdae01411f3236016ecd1f81/hypothesis-6.168.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7", size = 1168962, upload-time = "2026-09-08T18:46:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/11/f1/16ec2bddbaed461725d9aa5a80b43f1905ea50a08f689ec46f8966bb4f0f/hypothesis-6.168.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9", size = 1298932, upload-time = "2026-09-08T18:47:40.632Z" }, + { url = "https://files.pythonhosted.org/packages/8f/12/7c2fe2706d092f12bd7b3e8565e1ca5d0c24b853751f2f970768086dbdeb/hypothesis-6.168.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6", size = 1335196, upload-time = "2026-09-08T18:47:57.775Z" }, + { url = "https://files.pythonhosted.org/packages/20/35/59f7ca2414ca39408d13f66a344affe0ffc64748dc01d8a1ca910009cdcb/hypothesis-6.168.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27", size = 624102, upload-time = "2026-09-08T18:47:38.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1e/dcd9335ace916ffea40f2cb04ba4122094c2f7b928f3a73fc7d452ce5b71/hypothesis-6.168.0-cp314-cp314-win_amd64.whl", hash = "sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193", size = 681871, upload-time = "2026-09-08T18:47:59.697Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/bc50b0b91e40744b7caa56b8add85cef432f85b4d00108409e8eb17af830/hypothesis-6.168.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1", size = 791695, upload-time = "2026-09-08T18:47:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/4b/53/fc7537d50ff008dc5ea8598764935f93dd07bcedaf23ee4e635bdf7055f4/hypothesis-6.168.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd", size = 783239, upload-time = "2026-09-08T18:47:47.43Z" }, + { url = "https://files.pythonhosted.org/packages/71/2a/c7aac2efc06713f704d7e354755aff4a11608b9fe93d973ead374f3b81a3/hypothesis-6.168.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507", size = 1121412, upload-time = "2026-09-08T18:47:07.927Z" }, + { url = "https://files.pythonhosted.org/packages/60/e2/668ab29e5096af682b17b8491f5427d7c5f17c1b991daa5577bde80c29ca/hypothesis-6.168.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4", size = 1167570, upload-time = "2026-09-08T18:47:17.94Z" }, + { url = "https://files.pythonhosted.org/packages/3a/17/c64635e4c988b5fa3d3b8be322e19e2fe4c731fa0ca074ca852aa70debea/hypothesis-6.168.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8", size = 1297118, upload-time = "2026-09-08T18:48:13.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/c7a0d9a06bf5c3279386dd53161081a57b98c6faf60fbbf64d046315e9e6/hypothesis-6.168.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571", size = 1334068, upload-time = "2026-09-08T18:48:15.462Z" }, + { url = "https://files.pythonhosted.org/packages/32/99/11a393a20a867e9b978308323d45022e96f5cd2edf391e4d9a65fb4e2cf7/hypothesis-6.168.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8", size = 681795, upload-time = "2026-09-08T18:47:14.415Z" }, + { url = "https://files.pythonhosted.org/packages/16/f7/5adae1bf1d4877aca2e8c8e007e57077237e9ac3765e430ffda490b17e19/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128", size = 791056, upload-time = "2026-09-08T18:48:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/f2/93/b1b2770b87591db5cf564b9aa0265cf21e9207d28645e0abdeb8df63225b/hypothesis-6.168.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795", size = 782980, upload-time = "2026-09-08T18:48:34.087Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/673171c1d2423379a7d4a0f9f009cca735a422d0c4a4ac6422d1d1736cab/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441", size = 1120964, upload-time = "2026-09-08T18:46:59.92Z" }, + { url = "https://files.pythonhosted.org/packages/7d/00/33a9bd941b22a4fd8a8c805b1563e0db17efc020422f5bd15bdd0fdf258f/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63", size = 1143869, upload-time = "2026-09-08T18:46:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c1/963460976f41721eff8f67f30d31059ea407cc1b41c737c8859aabf37197/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782", size = 1146453, upload-time = "2026-09-08T18:46:25.757Z" }, + { url = "https://files.pythonhosted.org/packages/ce/53/09db238098ad66f4e6d2fe883f26c270c2595b90e21c8f969d4cb21cad7a/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc", size = 1166918, upload-time = "2026-09-08T18:48:23.436Z" }, + { url = "https://files.pythonhosted.org/packages/b9/31/e1b7b452c8a6166e445ba2ad80a864f6a9eee0fe4c8cecdb9af5c1ee0aa5/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e", size = 1126637, upload-time = "2026-09-08T18:47:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/2b/4eceed248afb46fb6b2df21cf2239362de5b25d295c2dc67a82ec8657d5e/hypothesis-6.168.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c", size = 1155682, upload-time = "2026-09-08T18:47:30.234Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/f3414cda4f325004d5774485e8983b7d1b99e4b91013f013dd088fd778cf/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9", size = 1296464, upload-time = "2026-09-08T18:46:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/aa/40/ca79cf96545e1f172b36b8df56bfeb02b61f351f283026f9a57c4631e368/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad", size = 1421853, upload-time = "2026-09-08T18:47:23.083Z" }, + { url = "https://files.pythonhosted.org/packages/77/bc/657d5386740c1f4ac518ff05e4c5b132f1e6df2e7c865ba8fda4279adfa5/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f", size = 1278216, upload-time = "2026-09-08T18:46:30.871Z" }, + { url = "https://files.pythonhosted.org/packages/51/54/2328cdb70489a36634534478d9b238594a269d8bec4630ea0e6897048347/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09", size = 1297593, upload-time = "2026-09-08T18:46:41.395Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ca/d803fa57e3ff7f460f6e262d2b74822cf143343d378501fd3da601b12040/hypothesis-6.168.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887", size = 1333785, upload-time = "2026-09-08T18:48:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/86/8f/b9799ae6ba6074f151db2c63f9f6f12d844512821ffaba1a3672d7e59f07/hypothesis-6.168.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780", size = 675173, upload-time = "2026-09-08T18:47:52.601Z" }, + { url = "https://files.pythonhosted.org/packages/61/54/14c3e277b451ff24128ecc2673cac59dd7e535bce1a433c466912fd682e1/hypothesis-6.168.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef", size = 681491, upload-time = "2026-09-08T18:46:51.437Z" }, + { url = "https://files.pythonhosted.org/packages/99/f3/827e4a48ffee7e40244b0bf064ba47c2171e053ab7edf1cf770105e23401/hypothesis-6.168.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a", size = 679197, upload-time = "2026-09-08T18:47:32.112Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -1234,6 +1306,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "soupsieve" version = "2.9.2" @@ -1435,6 +1516,7 @@ docs = [ [package.dev-dependencies] dev = [ + { name = "hypothesis" }, { name = "codespell" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1458,6 +1540,7 @@ provides-extras = ["docs"] [package.metadata.requires-dev] dev = [ + { name = "hypothesis", specifier = ">=6.100" }, { name = "codespell", specifier = ">=2.3" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" },