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/pre-commit.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Pre-commit / lint check.
#
# Runs `pre-commit run --all-files` (black, isort, flake8, pyupgrade, mypy,
# Runs `pre-commit run --all-files` (ruff-check, ruff-format, mypy,
# end-of-file-fixer, trailing-whitespace, check-merge-conflict, debug-statements).
# ~30 s wall. Runs on every PR + push to main.

Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/test-fast.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Fast tests (PR gate).
#
# `pytest -m fast -n 2` on Python 3.11 + 3.13 matrix. ~1.5 min wall per Python.
# `pytest -m fast -n 2` on Python 3.13 + 3.14 matrix. ~1.5 min wall per Python.
# 676 tests in the current `fast` set (no JAX trace, < 100 ms each).
# Runs on every PR + push to main.

Expand All @@ -23,8 +23,14 @@ jobs:
strategy:
fail-fast: false
matrix:
# pyproject declares ``requires-python = ">=3.13"``. Matrix matches.
python: ['3.13']
# pyproject declares ``requires-python = ">=3.13"``. Matrix covers
# both supported Pythons (3.13 and 3.14).
python: ['3.13', '3.14']
env:
# Pin every uv command to the matrix Python. Without this, `uv run`
# falls back to .python-version (3.13) and rebuilds the env without
# the bench group, so the 3.14 leg fails with "Failed to spawn: pytest".
UV_PYTHON: ${{ matrix.python }}
steps:
- name: Check out repo
uses: actions/checkout@v4
Expand Down
54 changes: 19 additions & 35 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,50 +1,34 @@
# Lint stack for tuningfork. Versions bumped from sampling-book's 2022-era pins
# so they install cleanly on Python 3.13. Tools and ignore lists kept identical
# in spirit to sampling-book/blackjax for cross-repo consistency.
# Lint stack for tuningfork. ruff (ruff-check + ruff-format) replaces
# isort/pyupgrade/flake8/black/nbQA — one tool, one config, native Jupyter
# notebook support if any .ipynb ever lands. Tools kept identical in spirit
# to sampling-book/blackjax for cross-repo consistency.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-merge-conflict
- id: debug-statements
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
args: [--profile, black]
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.0
hooks:
- id: pyupgrade
args: [--py311-plus]
- repo: https://github.com/PyCQA/flake8
rev: 7.1.1
hooks:
- id: flake8
args: ['--ignore=E501,E203,E731,W503']
exclude: '^scripts/'
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.10
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
# ruff-format's hook default also formats fenced code blocks in
# markdown (types_or includes "markdown"); the old stack
# (black/isort/flake8/nbQA) never touched .md prose docs, only .py
# and .ipynb. Keep that scope so curated doc formatting (aligned
# inline comments in catalog READMEs/schemas) isn't rewritten as a
# side effect of this migration.
types_or: [python, pyi, jupyter]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
rev: v2.4.0
hooks:
- id: mypy
args: [--ignore-missing-imports]
exclude: '^scripts/'
- repo: https://github.com/nbQA-dev/nbQA
rev: 1.9.0
hooks:
- id: nbqa-black
- id: nbqa-pyupgrade
args: [--py311-plus]
- id: nbqa-isort
args: ['--profile=black']
- id: nbqa-flake8
args: ['--ignore=E501,E203,E302,E402,E731,W503']
- repo: https://github.com/jorisroovers/gitlint
rev: v0.19.1
hooks:
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ All `test-*` targets automatically run `make clean-orphans` first (except `test-
assert logp == 0.0
```

5. **Run `make lint` before committing** to pass pre-commit hooks (black, isort, flake8, mypy).
5. **Run `make lint` before committing** to pass pre-commit hooks (ruff, mypy).

## Pre-Commit and Commit Messages

Expand Down
1 change: 1 addition & 0 deletions benchmarks/_benchmark_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

Used by both test_fast_recipes.py and test_e2e_recipes.py.
"""

from __future__ import annotations

import json
Expand Down
1 change: 1 addition & 0 deletions benchmarks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
The split keeps the fast suite quick enough for local smoke + targeted CI
triggers, while the slow e2e cells run nightly only.
"""

from __future__ import annotations

# ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions benchmarks/test_e2e_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
DO NOT add these to ``make benchmark-fast`` or per-PR triggers — the wall
time makes them unsuitable for quick local checks.
"""

from __future__ import annotations

from typing import Any
Expand Down
1 change: 1 addition & 0 deletions benchmarks/test_fast_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Cell selection: see ``benchmarks/config.py`` (FAST_CELLS, ≤60s/cell in CI).
Slow e2e cells (>60s): see ``benchmarks/test_e2e_recipes.py``.
"""

from __future__ import annotations

from typing import Any
Expand Down
1 change: 1 addition & 0 deletions benchmarks/test_speed_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
Trend persisted in ``actions/cache`` (not gh-pages).
Alert at 200% with ``fail-on-alert: true`` (nightly red = regression signal).
"""

from __future__ import annotations

from pathlib import Path
Expand Down
20 changes: 18 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,24 @@ exclude = [
[tool.setuptools_scm]
write_to = "tuningfork/_version.py"

[tool.isort]
profile = "black"
[tool.ruff]
target-version = "py313"
line-length = 88

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"] # = the old flake8 (pycodestyle+pyflakes) + isort + pyupgrade
ignore = [
"E501", # line length: ruff-format owns it (was ignored before)
"E731", # lambda assignment (was ignored before)
"UP040", "UP046", "UP047", # PEP 695 `type`/generic rewrites: change runtime semantics, out of scope
"UP042", # str+Enum -> StrEnum changes str()/f-string output ("Effort.LOW" ->
# "low"); test_schema.py:61 asserts the current str() form and
# _resolve_execution_plan.py builds a file path from an f-strung
# effort value, so this rewrite is a behavior change, not a style fix
]

[tool.ruff.lint.isort]
known-first-party = ["tuningfork"]

[tool.pytest.ini_options]
filterwarnings = [
Expand Down
7 changes: 4 additions & 3 deletions tests/e2e/test_nightly_regression.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
make test-slow
JAX_PLATFORM_NAME=cpu uv run pytest tests/e2e/test_nightly_regression.py -v
"""

from __future__ import annotations

import pytest
Expand Down Expand Up @@ -81,9 +82,9 @@ def test_lotka_dense_imm_inner_nuts_seed_20260713_passes() -> None:
catalog_root=catalog,
)
z = compute_max_abs_mean_z(idata, "lotka_volterra")
assert (
z is not None
), "compute_max_abs_mean_z returned None — reference/summary.json missing?"
assert z is not None, (
"compute_max_abs_mean_z returned None — reference/summary.json missing?"
)
assert z < 4.0, (
f"Seed 20260713 should pass z < 4.0 (it is a clean seed), got z={z:.3f}. "
"If this seed now fails, the step-collapse has worsened — see issue #232."
Expand Down
12 changes: 6 additions & 6 deletions tests/e2e/test_phase1.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ def test_mvn_cache_hit_second_run(self, tmp_path: Path) -> None:
second_meta = json.load(fh)
second_ts = second_meta["timestamp_utc"]

assert (
first_ts == second_ts
), f"Cache hit expected but timestamp changed: {first_ts!r} → {second_ts!r}"
assert first_ts == second_ts, (
f"Cache hit expected but timestamp changed: {first_ts!r} → {second_ts!r}"
)

def test_mvn_force_regenerates(self, tmp_path: Path) -> None:
"""--force must update the timestamp (regeneration happened)."""
Expand All @@ -94,9 +94,9 @@ def test_mvn_force_regenerates(self, tmp_path: Path) -> None:
second_meta = json.load(fh)
second_ts = second_meta["timestamp_utc"]

assert (
first_ts != second_ts
), f"--force expected regeneration but timestamp did not change: {first_ts!r}"
assert first_ts != second_ts, (
f"--force expected regeneration but timestamp did not change: {first_ts!r}"
)

def test_mvn_output_contains_summary(self, tmp_path: Path) -> None:
"""CLI must print a summary table with expected fields."""
Expand Down
66 changes: 33 additions & 33 deletions tests/e2e/test_phase3.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,32 +47,32 @@ class TestLeaderboardCLI:
def test_leaderboard_mvn_10_markdown(self, tmp_path: Path) -> None:
"""tuningfork leaderboard mvn_10 must exit 0 and print markdown table."""
result = _run_leaderboard(["mvn_10"], tmp_path)
assert (
result.returncode == 0
), f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
assert result.returncode == 0, (
f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
stdout = result.stdout
# Table header and title must be present
assert (
"Leaderboard for mvn_10" in stdout
), f"'Leaderboard for mvn_10' missing from stdout:\n{stdout}"
assert (
"| effort |" in stdout
), f"Markdown table header missing from stdout:\n{stdout}"
assert "Leaderboard for mvn_10" in stdout, (
f"'Leaderboard for mvn_10' missing from stdout:\n{stdout}"
)
assert "| effort |" in stdout, (
f"Markdown table header missing from stdout:\n{stdout}"
)
assert "|" in stdout, f"Table pipes missing from stdout:\n{stdout}"
# At least a few rows expected
lines = stdout.strip().split("\n")
table_lines = [line for line in lines if line.startswith("|")]
# header + separator + at least 2 data rows
assert (
len(table_lines) >= 4
), f"Expected at least 2 data rows in markdown table, got {len(table_lines)} table lines:\n{stdout}"
assert len(table_lines) >= 4, (
f"Expected at least 2 data rows in markdown table, got {len(table_lines)} table lines:\n{stdout}"
)

def test_leaderboard_mvn_10_effort_medium(self, tmp_path: Path) -> None:
"""tuningfork leaderboard mvn_10 --effort medium filters to MEDIUM rows."""
result = _run_leaderboard(["mvn_10", "--effort", "medium"], tmp_path)
assert (
result.returncode == 0
), f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
assert result.returncode == 0, (
f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
stdout = result.stdout
assert "Leaderboard for mvn_10" in stdout
# Count MEDIUM-effort rows (ignoring header/separator)
Expand All @@ -82,29 +82,29 @@ def test_leaderboard_mvn_10_effort_medium(self, tmp_path: Path) -> None:
for line in lines
if line.startswith("|") and "effort" not in line and "-----" not in line
]
assert (
len(data_lines) >= 1
), f"Expected at least 1 MEDIUM-effort row, got {len(data_lines)} rows:\n{stdout}"
assert len(data_lines) >= 1, (
f"Expected at least 1 MEDIUM-effort row, got {len(data_lines)} rows:\n{stdout}"
)
# All data rows should have "medium" in the effort column.
for line in data_lines:
parts = line.split("|")
effort_col = parts[1].strip()
assert (
effort_col == "medium"
), f"Expected 'medium' in effort column, got '{effort_col}' in line:\n{line}"
assert effort_col == "medium", (
f"Expected 'medium' in effort column, got '{effort_col}' in line:\n{line}"
)

def test_leaderboard_mvn_10_json_format(self, tmp_path: Path) -> None:
"""tuningfork leaderboard mvn_10 --format json must output JSON list."""
result = _run_leaderboard(["mvn_10", "--format", "json"], tmp_path)
assert (
result.returncode == 0
), f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
assert result.returncode == 0, (
f"Expected exit 0.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
# Parse JSON output
data = json.loads(result.stdout)
assert isinstance(data, list), f"Expected JSON list, got {type(data)}"
assert (
len(data) >= 1
), f"Expected at least 1 element in JSON list, got {len(data)}"
assert len(data) >= 1, (
f"Expected at least 1 element in JSON list, got {len(data)}"
)
# Verify schema
for item in data:
assert "effort" in item
Expand All @@ -116,11 +116,11 @@ def test_leaderboard_mvn_10_json_format(self, tmp_path: Path) -> None:
def test_leaderboard_bad_model_exits_2(self, tmp_path: Path) -> None:
"""tuningfork leaderboard does_not_exist must exit 2 and mention model in stderr."""
result = _run_leaderboard(["does_not_exist"], tmp_path)
assert (
result.returncode == 2
), f"Expected exit 2 for bad model.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
assert result.returncode == 2, (
f"Expected exit 2 for bad model.\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
)
# Stderr should mention model or list valid models
stderr_lower = result.stderr.lower()
assert (
"does_not_exist" in result.stderr or "model" in stderr_lower
), f"Expected bad-model name or 'model' in stderr:\n{result.stderr}"
assert "does_not_exist" in result.stderr or "model" in stderr_lower, (
f"Expected bad-model name or 'model' in stderr:\n{result.stderr}"
)
24 changes: 12 additions & 12 deletions tests/groundtruth/test_analytic_iid.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ def test_analytic_iid_smoke(model_name: str, tmp_path: Path) -> None:
with np.load(str(draws_path), allow_pickle=True) as draws:
for site in draws.files:
shape = draws[site].shape
assert (
shape[0] == _SMOKE_N_CHAINS
), f"{model_name}.{site}: expected n_chains={_SMOKE_N_CHAINS}, got {shape[0]}"
assert (
shape[1] == _SMOKE_N_DRAWS
), f"{model_name}.{site}: expected n_draws={_SMOKE_N_DRAWS}, got {shape[1]}"
assert shape[0] == _SMOKE_N_CHAINS, (
f"{model_name}.{site}: expected n_chains={_SMOKE_N_CHAINS}, got {shape[0]}"
)
assert shape[1] == _SMOKE_N_DRAWS, (
f"{model_name}.{site}: expected n_draws={_SMOKE_N_DRAWS}, got {shape[1]}"
)

# --- crude mean coherence vs committed GT ---
# At smoke scale (2 chains × 50 draws = 100 samples), the new mean can
Expand Down Expand Up @@ -123,9 +123,9 @@ def test_analytic_iid_smoke(model_name: str, tmp_path: Path) -> None:
"wrong distribution"
)
# Also check means are finite (catches NaN/Inf in sampler output)
assert np.all(
np.isfinite(new_mean)
), f"{model_name}.{site}: new mean contains non-finite values"
assert np.all(np.isfinite(new_mean)), (
f"{model_name}.{site}: new mean contains non-finite values"
)


def test_analytic_iid_verify_integration(tmp_path: Path) -> None:
Expand Down Expand Up @@ -197,6 +197,6 @@ def test_analytic_iid_custom_seed_differs(tmp_path: Path) -> None:
np.load(str(out2 / "draws.npz"), allow_pickle=True) as d2,
):
site = d1.files[0]
assert not np.allclose(
d1[site], d2[site]
), "Different seeds produced identical draws — RNG not working correctly"
assert not np.allclose(d1[site], d2[site]), (
"Different seeds produced identical draws — RNG not working correctly"
)
Loading
Loading