diff --git a/README.md b/README.md index 9eca6a9..bce5d23 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ python -m pytest python scripts/run_all.py ``` -Python 3.11+. SQLite is still the stdlib path for the main lab. DuckDB now runs in CI on that compact fixture. +Python 3.11+. SQLite is the stdlib path and the tests that do not need DuckDB run after `pip install -e .`. The DuckDB comparisons need the extra; they are skipped if it is not installed. CI installs `.[duckdb]`. ## Query design diff --git a/tests/test_cross_engine_parity.py b/tests/test_cross_engine_parity.py index 8f71bd3..eb74ab2 100644 --- a/tests/test_cross_engine_parity.py +++ b/tests/test_cross_engine_parity.py @@ -3,6 +3,7 @@ from __future__ import annotations import pandas as pd +import pytest from sqlfeat.cross_engine import ( duckdb_point_in_time_features, @@ -12,6 +13,17 @@ sqlite_rank_results, ) +try: + import duckdb as _duckdb # noqa: F401 +except ImportError: + _HAS_DUCKDB = False +else: + _HAS_DUCKDB = True + +_duckdb_only = pytest.mark.skipif( + not _HAS_DUCKDB, reason="install sqlfeat[duckdb] for the second-engine checks" +) + def _normalise(df: pd.DataFrame) -> pd.DataFrame: out = df.copy().sort_values("prediction_id").reset_index(drop=True) @@ -22,17 +34,21 @@ def _normalise(df: pd.DataFrame) -> pd.DataFrame: return out -def test_sqlite_duckdb_and_pandas_agree_on_point_in_time_features() -> None: +def test_sqlite_and_pandas_agree_on_point_in_time_features() -> None: sqlite = _normalise(sqlite_point_in_time_features()) - duckdb = _normalise(duckdb_point_in_time_features()) expected = _normalise(independent_expected_features()) - pd.testing.assert_frame_equal(sqlite, expected, check_dtype=False, atol=1e-12, rtol=0) - pd.testing.assert_frame_equal(duckdb, expected, check_dtype=False, atol=1e-12, rtol=0) + + +@_duckdb_only +def test_duckdb_agrees_with_sqlite_on_point_in_time_features() -> None: + sqlite = _normalise(sqlite_point_in_time_features()) + duckdb = _normalise(duckdb_point_in_time_features()) + pd.testing.assert_frame_equal(duckdb, sqlite, check_dtype=False, atol=1e-12, rtol=0) def test_future_sentinel_is_excluded_without_using_model_performance() -> None: - result = _normalise(duckdb_point_in_time_features()) + result = _normalise(sqlite_point_in_time_features()) row = result.loc[result["prediction_id"] == "p1"].iloc[0] assert row["txn_count"] == 2 assert row["spend"] == 30.0 @@ -48,13 +64,17 @@ def test_no_history_preserves_zero_aggregates_and_null_recency() -> None: assert pd.isna(row["recency_days"]) -def test_window_rank_semantics_match_across_engines() -> None: +def test_sqlite_rank_semantics() -> None: sqlite = sqlite_rank_results().sort_values("id").reset_index(drop=True) - duckdb = duckdb_rank_results().sort_values("id").reset_index(drop=True) - pd.testing.assert_frame_equal(sqlite, duckdb, check_dtype=False) - # Tied score 9 gets rank 1 in both rows; the next ordinary rank is 3, # while dense rank advances to 2. ROW_NUMBER is made deterministic by id. assert list(sqlite["rank_value"]) == [1, 1, 3, 4] assert list(sqlite["dense_rank_value"]) == [1, 1, 2, 3] assert list(sqlite["row_number_value"]) == [1, 2, 3, 4] + + +@_duckdb_only +def test_window_rank_semantics_match_across_engines() -> None: + sqlite = sqlite_rank_results().sort_values("id").reset_index(drop=True) + duckdb = duckdb_rank_results().sort_values("id").reset_index(drop=True) + pd.testing.assert_frame_equal(sqlite, duckdb, check_dtype=False)