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
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ jobs:

- name: 🏃🏻‍➡️ Test LKPY
run: |
mise run test -- --coverage --accel-coverage -- --log-file=test.log
mise run test -- --coverage --accel-coverage -- --log-level=debug

- name: 📤 Upload coverage to CodeCov
uses: codecov/codecov-action@v7.0.0
Expand Down
2 changes: 1 addition & 1 deletion .woodpecker/dataset-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ steps:
image: codeberg.org/mdekstrand/woodpecker-mise:v1-f44-dev
pull: true
settings:
task: "ci:prepare ::: test -v --coverage -m realdata ::: test-cli tests/cli/test-data-convert.sh"
task: "ci:data-tests"
mise-env: ci

- name: upload code coverage
Expand Down
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ numpy = "^0.29"
[dev-dependencies]
ntest = "^0.9"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(coverage_nightly)"] }

[profile.dev]
opt-level = 2
debug-assertions = true
Expand Down
2 changes: 2 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from hypothesis import settings
from pytest import fixture, register_assert_rewrite, skip

from lenskit.logging._accel import update_log_level # ruff: ignore[import-private-name]
from lenskit.parallel import ensure_parallel_init
from lenskit.random import init_global_rng

Expand Down Expand Up @@ -105,3 +106,4 @@ def pytest_collection_modifyitems(items):

settings.register_profile("default", deadline=1000)
ensure_parallel_init()
update_log_level()
18 changes: 18 additions & 0 deletions mise/tasks/ci/data-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/bin/zsh
#MISE description="Run data tests in CI"
#MISE depends=["ci:prepare"]
#USAGE flag "-v --verbose" help="Enable verbose logging."

. "$MISE_PROJECT_ROOT/mise/task-functions.sh"

echo-run mise run test -- -v --coverage -m realdata
if (($?)); then
die "tests failed"
fi

echo-run mise run test-cli --coverage --cov-append tests/cli/test-data-convert.sh
if (($?)); then
die "CLI tests failed"
fi

mise run coverage:export || die "coverage export failed"
5 changes: 5 additions & 0 deletions mise/tasks/coverage/export.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env zsh
#MISE description="Export test coverage data."
#MISE wait_for=["test", "test-cli"]

coverage xml
4 changes: 2 additions & 2 deletions src/accel/arrow/lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ impl ExtractListArray for LargeListArray {
if let Some(arr) = any.downcast_ref::<LargeListArray>() {
Some(arr.clone())
} else if let Some(arr) = any.downcast_ref::<ListArray>() {
info!("converting type {}", arr.data_type());
debug!("converting type {}", arr.data_type());
let (field, offsets, values, nulls) = arr.clone().into_parts();
info!("field: {}", field);
debug!("field: {}", field);
// convert offsets into Int64
let offsets: Vec<_> = offsets.iter().map(|o| *o as i64).collect();
let offsets = ScalarBuffer::from(offsets);
Expand Down
2 changes: 1 addition & 1 deletion src/accel/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Copyright (C) 2023-2026 Drexel University.
// Licensed under the MIT license, see LICENSE.md for details.
// SPDX-License-Identifier: MIT

#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
use pyo3::{exceptions::PyRuntimeError, prelude::*};

mod als;
Expand Down
12 changes: 10 additions & 2 deletions src/lenskit/_accel/data.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ def dense_cooc(
*,
diagonal: bool = True,
) -> AccelTask[np.ndarray[tuple[int, int], np.dtype[np.float32]]]: ...
def scatter_array(dst: _A, idx: pa.Array, src: _A) -> _A: ...
def scatter_array_empty(dst_size: int, idx: pa.Array, src: _A) -> _A: ...
def scatter_array(dst: _A, idx: pa.Array, src: _A) -> _A:
"""
Create a new array merging a base array with the scattered content of a second array.
"""

def scatter_array_empty(dst_size: int, idx: pa.Array, src: _A) -> _A:
"""
Scatter array elements into a new, otherwise-empty array.
"""

def sample_negatives(
coords: CoordinateTable,
rows: np.ndarray[tuple[int], np.dtype[np.int32]],
Expand Down
50 changes: 0 additions & 50 deletions src/lenskit/math/sparse.py

This file was deleted.

4 changes: 2 additions & 2 deletions src/lenskit/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def recommend(
if items is not None and not isinstance(items, ItemList):
items = ItemList(items)
res = pipeline.run(node, query=query, n=n, items=items, _profile=profiler)
if not isinstance(res, ItemList):
if not isinstance(res, ItemList): # pragma: nocover
raise TypeError("recommender pipeline did not return an item list")

return res
Expand Down Expand Up @@ -93,7 +93,7 @@ def score(
if items is not None and not isinstance(items, ItemList):
items = ItemList(items)
res = pipeline.run(node, query=query, items=items, _profile=profiler)
if not isinstance(res, ItemList):
if not isinstance(res, ItemList): # pragma: nocover
raise TypeError("scorer pipeline did not return an item list")

return res
Expand Down
3 changes: 1 addition & 2 deletions src/lenskit/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import hypothesis.strategies as st

from ._arrays import coo_arrays, scored_lists, sparse_arrays, sparse_tensors
from ._arrays import coo_arrays, scored_lists, sparse_arrays
from ._components import BasicComponentTests, ScorerTests
from ._movielens import (
DemoRecs,
Expand Down Expand Up @@ -52,7 +52,6 @@
"scored_lists",
"set_env_var",
"sparse_arrays",
"sparse_tensors",
]


Expand Down
12 changes: 0 additions & 12 deletions src/lenskit/testing/_arrays.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
from hypothesis import assume

from lenskit.data import ItemList
from lenskit.math.sparse import torch_sparse_from_scipy


@st.composite
Expand Down Expand Up @@ -76,17 +75,6 @@ def sparse_arrays(draw, *, layout="csr", **kwargs):
raise ValueError(f"invalid layout {layout}")


@st.composite
def sparse_tensors(draw, *, layout="csr", **kwargs):
if isinstance(layout, list):
layout = st.sampled_from(layout)
if isinstance(layout, st.SearchStrategy):
layout = draw(layout)

M: sps.coo_array = draw(coo_arrays(**kwargs))
return torch_sparse_from_scipy(M, layout) # type: ignore


@st.composite
def scored_lists(
draw: st.DrawFn,
Expand Down
19 changes: 0 additions & 19 deletions src/lenskit/torch.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,6 @@ def wrapper(*args, **kwargs):
return wrapper


def sparse_row(mat: torch.Tensor, row: int) -> torch.Tensor:
"""
Get a row of a sparse (CSR) tensor. This is needed because indexing a
tensor does not work in inference mode.
"""

assert mat.is_sparse_csr

cri = mat.crow_indices()
sp = cri[row]
ep = cri[row + 1]

cs = mat.col_indices()
vs = mat.values()
return torch.sparse_coo_tensor(
indices=cs[sp:ep].reshape(1, -1), values=vs[sp:ep], size=mat.shape[1:]
)


def safe_tensor(array) -> torch.Tensor:
"""
Safely convert an array into a NumPy tensor. This includes copying it to
Expand Down
14 changes: 14 additions & 0 deletions tests/accel/test_accel_arrow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
Test Arrow utility functions in the accelerator.
"""

import numpy as np
import pyarrow as pa

from lenskit import _accel


def test_array_type():
arr = pa.array(np.arange(10, dtype=np.int32))
t = _accel.arrow_type(arr)
assert t == "Int32"
10 changes: 8 additions & 2 deletions tests/accel/test_argsort.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import hypothesis.extra.numpy as nph
import hypothesis.strategies as st
from hypothesis import given
from pytest import mark
from pytest import mark, raises

from lenskit._accel import data

Expand Down Expand Up @@ -38,7 +38,7 @@ def test_sort_floats(arr):

@given(
nph.arrays(
nph.integer_dtypes(endianness="="),
st.one_of(nph.integer_dtypes(endianness="="), nph.unsigned_integer_dtypes(endianness="=")),
nph.array_shapes(max_dims=1),
elements={"allow_nan": False, "allow_infinity": False},
)
Expand Down Expand Up @@ -209,3 +209,9 @@ def test_topn_any_float(arr, n):
mask[np.isnan(arr)] = False
nopes = arr[mask]
assert np.all(nopes <= np.min(items))


def test_topn_rejects_strings():
strings = pa.array(["a", "b", "c", "x", "9", "0", "3", "@"])
with raises(TypeError):
data.argtopn(strings, 5)
59 changes: 59 additions & 0 deletions tests/accel/test_pyon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from lenskit import _accel


def test_parse_json():
json = """
{"name": "FOOBIE BLETCH", "tags": ["foo", "bar"]}
"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "FOOBIE BLETCH", "tags": ["foo", "bar"]}


def test_parse_single_quotes():
json = """
{'name': 'FOOBIE BLETCH', 'tags': ['foo', 'bar'], 'count': 7, 'active': false, 'value': 1.0}
"""
obj = _accel.data.pyon_loads(json)
assert obj == {
"name": "FOOBIE BLETCH",
"tags": ["foo", "bar"],
"count": 7,
"active": False,
"value": 1.0,
}


def test_parse_escape_dquote():
json = r"""{"name": "foo\"bob"}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": 'foo"bob'}


def test_parse_escape_squote():
json = r"""{"name": 'pe\'taq'}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "pe'taq"}


def test_parse_escape_tab():
json = r"""{"name": '\t'}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "\t"}


def test_parse_escape_lf():
json = r"""{"name": '\n'}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "\n"}


def test_parse_escape_cr():
json = r"""{"name": '\r'}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "\r"}


def test_parse_escape_unicode():
json = r"""{"name": '\u2230'}"""
obj = _accel.data.pyon_loads(json)
assert obj == {"name": "\u2230"}
14 changes: 14 additions & 0 deletions tests/accel/test_scatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import hypothesis.extra.numpy as nph
import hypothesis.strategies as st
from hypothesis import given
from pytest import raises

from lenskit._accel import data

Expand All @@ -21,6 +22,7 @@
nph.arrays(
st.one_of(
nph.integer_dtypes(endianness="="),
nph.unsigned_integer_dtypes(endianness="="),
nph.floating_dtypes(endianness="=", sizes=(16, 32, 64)),
),
nph.array_shapes(max_dims=1),
Expand Down Expand Up @@ -81,3 +83,15 @@ def test_scatter_dst_size(hd: st.DataObject, size, idx_t: np.dtype):
arr = arr_a.to_numpy(zero_copy_only=False)

assert np.array_equal(arr[idx], src, equal_nan=True)


def test_scatter_rejects_strings():
strings = pa.array(["a", "b", "c", "x", "9", "0", "3", "@"])
with raises(TypeError):
data.scatter_array(strings, pa.array([2, 7, 0]), strings)


def test_scatter_empty_rejects_strings():
strings = pa.array(["a", "b", "c", "x", "9", "0", "3", "@"])
with raises(TypeError):
data.scatter_array_empty(100, pa.array([2, 7, 0]), strings)
16 changes: 16 additions & 0 deletions tests/cli/test-data-convert.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,19 @@ if [[ -f data/australian_users_items.json.gz ]]; then
else
skip 3
fi

if [[ -f data/anonymous-msweb.data.gz ]]; then
run-lenskit data convert --ms-web data/anonymous-msweb.data.gz "$TEST_WORK/msweb"
require -d "$TEST_WORK/msweb"
require -f "$TEST_WORK/msweb/schema.json"
else
skip 3
fi

if [[ -f data/az23/Video_Games.csv.gz ]]; then
run-lenskit data convert --amazon data/az23/Video_Games.csv.gz "$TEST_WORK/az23-games"
require -d "$TEST_WORK/az23-games"
require -f "$TEST_WORK/az23-games/schema.json"
else
skip 3
fi
Loading
Loading