From 21a00a39de8803174b8532c8ab1443ec028e5354 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 15:50:54 -0400 Subject: [PATCH 01/14] docs(plans): add development, clone-performance, and benchmark plans Adds plans/ with three planning documents produced from an audit of the package: - development-plan.md: current-state assessment, confirmed defects, 8 workstreams (baseline/regression, packaging, python matrix, correctness, code quality, performance, repo size, docs) and sequencing. - repo-clone-performance.md: diagnosis of slow clones (notebook base64 outputs, historical package-data blobs, duplicated binary assets) with measurement commands and both non-destructive and history-rewrite fixes. - benchmarks-and-regression-testing.md: golden-output invariance harness and pytest-benchmark suite design to guarantee refactors do not alter scientific outputs. --- plans/benchmarks-and-regression-testing.md | 335 +++++++++++++++++++++ plans/development-plan.md | 185 ++++++++++++ plans/repo-clone-performance.md | 232 ++++++++++++++ 3 files changed, 752 insertions(+) create mode 100644 plans/benchmarks-and-regression-testing.md create mode 100644 plans/development-plan.md create mode 100644 plans/repo-clone-performance.md diff --git a/plans/benchmarks-and-regression-testing.md b/plans/benchmarks-and-regression-testing.md new file mode 100644 index 00000000..6fedde76 --- /dev/null +++ b/plans/benchmarks-and-regression-testing.md @@ -0,0 +1,335 @@ +# Benchmarks and Output-Invariance Regression Testing + +Purpose: guarantee that modernization work described in [`plans/development-plan.md`](development-plan.md) does **not** alter scientific outputs, and that performance does not silently regress. + +Two distinct suites: + +| Suite | Question it answers | Gate | +|---|---|---| +| **Invariance / golden-output** | Did the numbers change? | Hard fail on any unexplained diff | +| **Performance benchmark** | Did it get slower or use more memory? | Fail beyond a configured threshold | + +--- + +## 1. Baseline Capture + +### 1.1 Freeze the reference point + +```bash +git tag baseline/v0.13 +git push origin baseline/v0.13 +``` + +Record an exact environment so the baseline is reproducible: + +```bash +pip freeze > tests/regression/baseline-env-py311.txt +python -c "import platform,sys; print(platform.platform(), sys.version)" \ + >> tests/regression/baseline-env-py311.txt +``` + +### 1.2 Proposed layout + +``` +tests/ + regression/ + __init__.py + conftest.py # tolerance config, golden dir resolution, --update-golden flag + generate_golden.py # CLI: rebuild golden artifacts from current code + baseline-env-py311.txt + golden/ + match/ + match_neighborhood_tol0.parquet + match_neighborhood_tol0.1_nodup.parquet + match_neighborhood_tol0.1_dedup.parquet + recipe/ + permute_N1_seed1.parquet + permute_N5_seed1.parquet + make_recipe_ssp245_bcc.parquet + generate_gridded_recipe_mon.parquet + processing/ + rolling_mean_w9.parquet + chunk_ts_n9.parquet + get_chunk_info.parquet + stitch/ + gmat_stitching_bcc.parquet + gridded_stitching_bcc.sha256 # checksum only; NetCDF too large to vendor + archive/ + make_matching_archive_w9.parquet.sha256 + tas_archive_filenames.json + test_invariance_match.py + test_invariance_recipe.py + test_invariance_processing.py + test_invariance_stitch.py +benchmarks/ + __init__.py + conftest.py + bench_match.py + bench_recipe.py + bench_processing.py + bench_stitch.py + bench_io.py + asv.conf.json # optional: airspeed-velocity history tracking +``` + +Golden artifacts use **Parquet** (stable, typed, compact) rather than CSV to avoid float-formatting drift masking or creating diffs. Where an artifact would exceed ~1 MB, store a SHA-256 of a canonicalized serialization instead of the payload. + +--- + +## 2. Invariance Suite + +### 2.1 Functions that must be pinned + +Ordered by risk. Everything reachable from [`stitches/__init__.py`](../stitches/__init__.py) is public API and must be covered. + +| Target | Module | Determinism notes | +|---|---|---| +| `match_neighborhood` | [`stitches/fx_match.py`](../stitches/fx_match.py:235) | Deterministic; test `tol=0`, `tol=0.1`, both `drop_hist_duplicates` values | +| `internal_dist` | [`stitches/fx_match.py`](../stitches/fx_match.py:15) | Pure numeric | +| `drop_hist_false_duplicates` | [`stitches/fx_match.py`](../stitches/fx_match.py:95) | Tie-breaking on `min(idvalue)` is order-sensitive — pin explicitly | +| `shuffle_function` | [`stitches/fx_match.py`](../stitches/fx_match.py:81) | **Randomized** — must accept/lock a seed | +| `calculate_rolling_mean` | [`stitches/fx_processing.py`](../stitches/fx_processing.py:12) | `min_periods=1` edge behavior is the whole point; pin window ends | +| `chunk_ts`, `get_chunk_info` | [`stitches/fx_processing.py`](../stitches/fx_processing.py:63) | Deterministic | +| `subset_archive` | [`stitches/fx_processing.py`](../stitches/fx_processing.py:196) | Deterministic | +| `get_num_perms`, `remove_duplicates` | [`stitches/fx_recipe.py`](../stitches/fx_recipe.py:13) | Deterministic | +| `permute_stitching_recipes` | [`stitches/fx_recipe.py`](../stitches/fx_recipe.py:250) | **Randomized** via `group.sample(...)`; has a `testing=True` path that sets `random_state=1` — use it | +| `handle_transition_periods`, `handle_final_period` | [`stitches/fx_recipe.py`](../stitches/fx_recipe.py:678) | Deterministic | +| `generate_gridded_recipe`, `make_recipe` | [`stitches/fx_recipe.py`](../stitches/fx_recipe.py:931) | Depends on `pangeo_table.csv` — pin the data version | +| `gmat_stitching` | [`stitches/fx_stitch.py`](../stitches/fx_stitch.py:411) | Reads local `tas-data`; deterministic given data version | +| `gridded_stitching` | [`stitches/fx_stitch.py`](../stitches/fx_stitch.py:245) | Network (Pangeo) + writes NetCDF; mark `network`/`slow`, compare values not file bytes | +| `global_mean`, `get_ds_meta` | [`stitches/fx_data.py`](../stitches/fx_data.py:25) | Latitude-weighting math — pin against a synthetic grid | +| `calculate_anomaly`, `paste_historical_data` | [`stitches/make_tas_archive.py`](../stitches/make_tas_archive.py:104) | Deterministic; pin on a small synthetic frame | +| `make_matching_archive` | [`stitches/make_matching_archive.py`](../stitches/make_matching_archive.py:17) | Deterministic; checksum the output archive | +| `combine_df`, `anti_join`, `remove_obs_from_match`, `selstr` | [`stitches/fx_util.py`](../stitches/fx_util.py) | Cheap, fully deterministic — pure unit tests | + +### 2.2 Handling nondeterminism + +Two sources of randomness exist and both must be neutralized: + +1. [`stitches/fx_recipe.py`](../stitches/fx_recipe.py:446) — `group.sample(1, replace=False)` vs. `random_state=1` under `testing=True`. +2. [`stitches/fx_match.py`](../stitches/fx_match.py:81) — `shuffle_function`. + +Actions: + +- Invariance tests always call with `testing=True` / an explicit seed. +- **Add a `seed: int | None = None` parameter** to the public randomized entry points (a strictly additive, backward-compatible change) so users can reproduce results, and so tests do not depend on a `testing` flag that also alters other behavior. +- Additionally assert **statistical** invariance for the unseeded path: run K=200 unseeded draws, compare the distribution of `dist_l2` and the set of selected archive points against the baseline using a fixed-tolerance comparison of summary statistics. This catches changes to the *sampling space* even when individual draws differ. + +### 2.3 Comparison helpers + +```python +# tests/regression/conftest.py (sketch) +import pandas as pd +from pandas.testing import assert_frame_equal + +# Sorting removes any dependence on groupby/concat ordering, which is exactly +# the kind of incidental change a refactor is allowed to make. +def canonicalize(df: pd.DataFrame) -> pd.DataFrame: + df = df.reindex(sorted(df.columns), axis=1) + return df.sort_values(list(df.columns), kind="mergesort").reset_index(drop=True) + +def assert_matches_golden(actual, golden_path, rtol=0.0, atol=0.0): + expected = pd.read_parquet(golden_path) + assert_frame_equal( + canonicalize(actual), + canonicalize(expected), + check_dtype=False, # int64/int32 platform differences are acceptable + check_like=False, + rtol=rtol, + atol=atol, + ) +``` + +Tolerance policy: + +| Comparison | rtol / atol | +|---|---| +| Recipe tables, matches, chunk metadata (indices, years, labels) | exact (`0.0`) | +| Distances (`dist_l2`, `dist_dx`, `dist_fx`) | `rtol=1e-12` | +| Global-mean temperature values | `rtol=1e-10` | +| Gridded NetCDF field values | `rtol=1e-6`, `atol=1e-9` | + +Rationale: anything integral or categorical must be bit-exact; floating-point reductions may legitimately reassociate across NumPy/pandas versions, so a tight-but-nonzero tolerance avoids false alarms while still catching real algorithmic change. + +### 2.4 Test skeleton + +```python +# tests/regression/test_invariance_match.py (sketch) +import pandas as pd +import pytest +from stitches.fx_match import match_neighborhood +from .conftest import assert_matches_golden + +@pytest.mark.parametrize( + "tol,dedup,golden", + [ + (0.0, True, "match/match_neighborhood_tol0.parquet"), + (0.1, False, "match/match_neighborhood_tol0.1_nodup.parquet"), + (0.1, True, "match/match_neighborhood_tol0.1_dedup.parquet"), + ], +) +def test_match_neighborhood_invariant(golden_dir, tol, dedup, golden): + target = pd.read_csv("tests/test-target_dat.csv") + archive = pd.read_csv("tests/test-archive_dat.csv") + out = match_neighborhood(target, archive, tol=tol, drop_hist_duplicates=dedup) + assert_matches_golden(out, golden_dir / golden, rtol=1e-12) +``` + +### 2.5 Regenerating golden files + +```bash +# Explicit, never automatic +python -m tests.regression.generate_golden --all +python -m tests.regression.generate_golden --only recipe +pytest tests/regression --update-golden # equivalent, opt-in flag +``` + +Policy: a PR that changes any file under `tests/regression/golden/` **must** include a `CHANGELOG.md` entry under `### Changed — outputs` explaining which defect the new output corrects, and must be reviewed by a domain maintainer, not just a code reviewer. + +### 2.6 Fixtures and the data dependency + +`tests/conftest.py` currently calls `stitches.install_package_data()` in a session fixture, which downloads the entire Zenodo archive. + +- Pin the expected data record explicitly (e.g. Zenodo `8367628`) and assert it, so an upstream data change cannot be mistaken for a code regression. +- Cache the download in CI keyed on the data version: + + ```yaml + - uses: actions/cache@v4 + with: + path: ~/.cache/stitches-data + key: stitches-data-8367628 + ``` + +- Add an offline tier: small committed fixtures (the existing `tests/test-*.csv`) cover `fx_match`, `fx_processing`, `fx_recipe`, and `fx_util` invariance with no network at all. Only `gridded_stitching` and the Pangeo tests need the network. +- Existing test CSVs are duplicated in both [`tests/`](../tests/) and [`stitches/data/example/`](../stitches/data/example/) — consolidate to one location referenced by both. + +--- + +## 3. Performance Benchmark Suite + +### 3.1 Tooling + +- `pytest-benchmark` for in-suite micro/meso benchmarks with JSON output and `--benchmark-compare-fail`. +- Optionally `asv` (airspeed velocity) for long-run history graphs across commits. +- `memory_profiler` / `tracemalloc` peak-RSS assertions for `gridded_stitching`, which is the memory-bound path. + +### 3.2 What to benchmark + +| Benchmark | Target | Why | +|---|---|---| +| `bench_match_neighborhood` | [`match_neighborhood`](../stitches/fx_match.py:235) at 1/10/50 target windows | Per-group Python loop, a WS-6 vectorization target | +| `bench_drop_hist_duplicates` | [`drop_hist_false_duplicates`](../stitches/fx_match.py:95) | Loop + `pd.concat` | +| `bench_permute_recipes` | [`permute_stitching_recipes`](../stitches/fx_recipe.py:250) at N=1/5/20 | Most algorithmically complex function in the package | +| `bench_make_recipe` | [`make_recipe`](../stitches/fx_recipe.py:1023) | End-to-end user path | +| `bench_rolling_mean` | [`calculate_rolling_mean`](../stitches/fx_processing.py:12) | `groupby.transform` with a lambda | +| `bench_chunk_ts` | [`chunk_ts`](../stitches/fx_processing.py:63) | Hot inner helper | +| `bench_matching_archive` | [`make_matching_archive`](../stitches/make_matching_archive.py:17) | Nested `for offset × groupby` loop | +| `bench_gmat_stitching` | [`gmat_stitching`](../stitches/fx_stitch.py:411) | Reads all `tas-data` CSVs | +| `bench_load_data_files` | [`load_data_files`](../stitches/fx_util.py:205) | CSV→Parquet migration candidate | +| `bench_pangeo_table_read` | [`fx_recipe.py`](../stitches/fx_recipe.py:980) repeated `read_csv` | Caching candidate | +| `bench_gridded_stitching` | [`gridded_stitching`](../stitches/fx_stitch.py:245) | `slow`+`network`; nightly only, tracks wall time and peak RSS | + +### 3.3 Example + +```python +# benchmarks/bench_match.py (sketch) +import pandas as pd +import pytest +from stitches.fx_match import match_neighborhood + +@pytest.fixture(scope="module") +def frames(): + return ( + pd.read_csv("tests/test-target_dat.csv"), + pd.read_csv("tests/test-archive_dat.csv"), + ) + +@pytest.mark.parametrize("tol", [0.0, 0.1, 0.5]) +def test_bench_match_neighborhood(benchmark, frames, tol): + target, archive = frames + result = benchmark(match_neighborhood, target, archive, tol=tol) + assert len(result) > 0 +``` + +### 3.4 Thresholds and CI wiring + +```bash +# Store the baseline once, on the tagged commit +pytest benchmarks --benchmark-only --benchmark-save=baseline + +# On every PR +pytest benchmarks --benchmark-only \ + --benchmark-compare=baseline \ + --benchmark-compare-fail=mean:25% +``` + +- Fail a PR at **>25% mean regression** on any benchmark (generous, because GitHub runners are noisy). +- Run benchmarks on a **single fixed OS/Python combination** (ubuntu-latest / 3.11) to keep numbers comparable; never gate on the full matrix. +- Use `--benchmark-min-rounds` and warmup to reduce variance; treat sub-10ms benchmarks as informational only. +- Nightly scheduled job runs the `slow`/`network` benchmarks and posts results as an artifact. + +### 3.5 Marker configuration + +Add to `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "network: requires internet access (Pangeo, Zenodo)", + "slow: long-running (>30s)", + "regression: golden-output invariance test", + "benchmark: performance measurement, not correctness", +] +addopts = "-m 'not benchmark'" +``` + +This replaces the current `RUN = "ci"` class attributes in [`tests/test_stitch.py`](../tests/test_stitch.py:28) and [`tests/test_pangeo.py`](../tests/test_pangeo.py:17), which make tests *pass vacuously* (`self.assertEqual(0, 0)`) rather than skip visibly. + +--- + +## 4. CI Integration + +```mermaid +flowchart TD + PR[Pull request] --> L[Lint / pre-commit] + PR --> U[Unit tests, matrix, offline fixtures] + PR --> R[Invariance suite, ubuntu + 3.11, cached data] + PR --> B[Benchmarks vs baseline, ubuntu + 3.11] + L --> G{All green} + U --> G + R --> G + B --> G + G -->|yes| M[Merge] + N[Nightly schedule] --> NW[network + slow + gridded benchmarks] + N --> ND[latest-dependencies resolve] +``` + +Jobs to add to [`.github/workflows/workflow.yml`](../.github/workflows/workflow.yml) (or split into separate workflows): + +1. `test` — existing, upgraded to `actions/checkout@v4` / `setup-python@v5`, with `cache: pip`, filtered checkout, and coverage uploaded to Codecov. +2. `regression` — installs cached package data, runs `pytest tests/regression -m "regression and not network"`. +3. `benchmark` — runs `pytest benchmarks --benchmark-compare`, uploads the JSON artifact. +4. `nightly` — cron; network + slow + gridded, plus an unpinned dependency resolve to catch upstream breakage. + +Add `concurrency: { group: ${{ github.ref }}, cancel-in-progress: true }` to stop stacked runs. + +--- + +## 5. Implementation Order + +1. Add pytest markers and delete the `RUN = "ci"` vacuous-pass pattern. +2. Add `tests/regression/` scaffolding, `canonicalize`/`assert_matches_golden`, and the `--update-golden` flag. +3. Add `seed` parameters to the randomized entry points (additive only, no behavior change when omitted). +4. Generate golden artifacts at `baseline/v0.13` and commit them. +5. Add `benchmarks/` and save the `baseline` benchmark run. +6. Wire the `regression` and `benchmark` CI jobs; require them on `main`. +7. Only then begin WS-2 onward from [`plans/development-plan.md`](development-plan.md). + +## 6. Definition of Done + +- Every function exported from [`stitches/__init__.py`](../stitches/__init__.py) has at least one invariance test. +- Zero vacuously-passing tests; all skips are marker-driven and reported in the summary. +- Golden files regenerate reproducibly from a clean checkout via one documented command. +- Benchmark baseline committed; PR comparison gate active. +- The regeneration policy (changelog entry + domain review) is documented in [`docs/source/reference/contributing.rst`](../docs/source/reference/contributing.rst). diff --git a/plans/development-plan.md b/plans/development-plan.md new file mode 100644 index 00000000..16e14453 --- /dev/null +++ b/plans/development-plan.md @@ -0,0 +1,185 @@ +# `stitches` Development Plan + +Status: draft for review +Scope: modernization, correctness, performance, repository hygiene, and regression protection for the `stitches-emulator` package (current version `0.13`). + +Related documents: + +- [`plans/repo-clone-performance.md`](repo-clone-performance.md) — why cloning is slow and how to fix it +- [`plans/benchmarks-and-regression-testing.md`](benchmarks-and-regression-testing.md) — output-invariance harness and performance benchmarks + +--- + +## 1. Current State Assessment + +### 1.1 Package layout + +| Area | Observation | +|---|---| +| Build system | Legacy [`setup.py`](../setup.py) + near-empty [`setup.cfg`](../setup.cfg) + [`MANIFEST.in`](../MANIFEST.in); no `pyproject.toml`, no PEP 517/518 build backend | +| Version | [`stitches/_version.py`](../stitches/_version.py) pins `0.13`, hand-maintained and duplicated in [`stitches/install_pkgdata.py`](../stitches/install_pkgdata.py) URL map and in [`CITATION.cff`](../CITATION.cff) | +| Python support | `python_requires=">=3.9.0"`; CI matrix is 3.9–3.11 only. 3.9 is end-of-life; 3.12/3.13 untested | +| Dependencies | [`requirements.txt`](../requirements.txt) uses open-ended `>=` pins only; **`requests` is imported by [`stitches/install_pkgdata.py`](../stitches/install_pkgdata.py:11) but is not declared anywhere** | +| CI | [`.github/workflows/workflow.yml`](../.github/workflows/workflow.yml) uses `actions/checkout@v3` and `actions/setup-python@v4` (outdated), computes coverage but never uploads it, has no pip cache, no package-data cache, and no concurrency cancellation | +| Pre-commit | [`.pre-commit-config.yaml`](../.pre-commit-config.yaml) hooks are pinned to mid-2023 revisions (black 23.3.0, flake8 6.0.0, isort 5.12.0, pyupgrade v3.8.0). No `ruff`; three overlapping linters instead | +| Tests | 6 test modules, `unittest`-style, gated by hardcoded `RUN = "ci"` flags that silently skip real assertions; session fixture in [`tests/conftest.py`](../tests/conftest.py:16) downloads the full Zenodo archive on every run | +| Docs | Sphinx site present; API reference and quickstarter exist but reference an older workflow; no changelog | +| Data | All science data lives off-repo on Zenodo (record `8367628`) and is installed at runtime into `stitches/data/` | + +### 1.2 Confirmed defects to fix (behavior-changing, currently incorrect) + +These are bugs where "changing the output" is the *point*. Each needs a regression test written **before** the fix. + +1. **`make_tas_archive` writes bad filenames / crashes.** In [`stitches/make_tas_archive.py`](../stitches/make_tas_archive.py:453): + ```python + for name, group in data.groupby(["model"]): + path = tas_data_dir + "/" + name + "_tas.csv" + ``` + - `tas_data_dir` is an `importlib.resources` `Traversable`, so `Traversable + str` raises `TypeError`. + - With pandas ≥ 2.0, grouping by a **list** of one key yields a one-element **tuple** as `name`, so even after fixing the path join the filenames would become `('BCC-CSM2-MR',)_tas.csv`. + - Fix: `os.path.join(str(tas_data_dir), f"{name}_tas.csv")` and `groupby("model")` (or unpack the tuple). +2. **Missing runtime dependency `requests`.** Fresh installs fail at `stitches.install_package_data()` unless `requests` happens to be present transitively. +3. **`install_pkgdata` robustness.** In [`stitches/install_pkgdata.py`](../stitches/install_pkgdata.py:53): + - `os.mkdir` instead of `os.makedirs(..., exist_ok=True)`. + - `requests.get(...)` has no `timeout`, no `raise_for_status()`, and buffers the entire (~GB-scale) zip into memory via `BytesIO`. + - `tqdm` is imported but unused; the "progress" message is a bare `print`. + - The version→URL dict must be manually extended for every release; a `DEFAULT_VERSION` fallback silently downloads a possibly mismatched dataset. + - Only files whose path contains `tas-data` are re-nested; `temp-data` is created but never populated. +4. **Unused/dead imports and `# noqa`-adjacent lint debt** — flake8 is currently configured to ignore `F401`, masking real dead imports. +5. **Pandas/xarray/NumPy deprecation exposure** — grouped `.agg` with lambdas, `groupby(list_of_one)`, positional `dim` args to `xarray` reductions, and `intake-esm` pinned to a 2021 release. These will break under newer stacks even though outputs are currently correct. + +### 1.3 Repository clone problem (summary) + +The working tree is small; the **git history and binary blobs** are the cost. Primary suspects, in order of impact: + +1. Jupyter notebooks committed **with base64-encoded PNG outputs** — [`notebooks/stitches-quickstart.ipynb`](../notebooks/stitches-quickstart.ipynb), [`notebooks/preparing-input-data.ipynb`](../notebooks/preparing-input-data.ipynb), [`notebooks/stitches_training_GCAMAnnualMeeting2023.ipynb`](../notebooks/stitches_training_GCAMAnnualMeeting2023.ipynb), [`notebooks/stitches_takehome_GCAMAnnualMeeting2023.ipynb`](../notebooks/stitches_takehome_GCAMAnnualMeeting2023.ipynb). Every re-run rewrites megabytes of unrelated base64, so **each historical commit stores a full new copy**. +2. Historical commits of package data (`stitches/data/tas-data/*.csv`, `*.nc`, `matching_archive*.csv`) that were later `.gitignore`d — ignoring a file does not remove it from history. +3. Duplicated raster assets (`stitches_diagram.jpg` stored three times under [`docs/source/getting-started/`](../docs/source/getting-started/), [`notebooks/figs/`](../notebooks/figs/), and [`paper/`](../paper/)) plus committed Sphinx `output_*.png` renders. + +Full diagnosis and remediation options are in [`plans/repo-clone-performance.md`](repo-clone-performance.md). + +--- + +## 2. Guiding Principle: Output Invariance + +> Any refactor, dependency bump, or performance change must produce **bit-comparable or tolerance-comparable outputs** to the current release, except where the current output is provably wrong. + +Enforcement mechanism: + +1. Freeze "golden" outputs from the current code at a tagged baseline commit (`baseline/v0.13`). +2. Store golden artifacts as small, compressed fixtures (or checksums for large ones) under `tests/regression/`. +3. Every PR runs the invariance suite; deliberate output changes require updating the golden fixture **in the same PR** with a written justification in the changelog. + +```mermaid +flowchart LR + A[Tag baseline v0.13] --> B[Record golden outputs] + B --> C[Refactor / modernize] + C --> D[Run invariance suite] + D -->|identical| E[Merge] + D -->|differs| F[Is current output wrong?] + F -->|yes| G[Update golden + changelog entry] + F -->|no| H[Fix the regression] + G --> E + H --> D +``` + +--- + +## 3. Workstreams + +### WS-1 — Baseline and regression safety net (must land first) + +- Tag `baseline/v0.13` and pin a fully resolved environment lockfile for reproducibility. +- Build the golden-output harness and performance benchmarks per [`plans/benchmarks-and-regression-testing.md`](benchmarks-and-regression-testing.md). +- Remove the `RUN = "ci"` escape hatches in [`tests/test_stitch.py`](../tests/test_stitch.py:28) and [`tests/test_pangeo.py`](../tests/test_pangeo.py:17); replace with `pytest` markers (`-m "not network"`, `-m "not slow"`) so skips are explicit and countable. +- Cache the Zenodo package data in CI so [`tests/conftest.py`](../tests/conftest.py) does not re-download per job. + +### WS-2 — Packaging and build modernization + +- Replace [`setup.py`](../setup.py)/[`setup.cfg`](../setup.cfg) with a PEP 621 `pyproject.toml` (setuptools backend), moving dependencies, `extras_require`, classifiers, and metadata inline. +- Single-source the version: keep `stitches/_version.py` as the authority (`dynamic = ["version"]`) or adop`setuptools-scm`; then derive `CITATION.cff` and docs `conf.py` from it. +- Declare `requests` as a runtime dependency; add upper bounds or a tested-versions matrix for `pandas`, `xarray`, `numpy`, `intake-esm`. +- Convert `MANIFEST.in` content to `[tool.setuptools.package-data]` where possible; verify wheel/sdist contents with `check-manifest` and `twine check`. +- Replace `twine~=3.4.1` dev pin with a current release; add a `build`+`twine` release workflow triggered on tags with PyPI Trusted Publishing. + +### WS-3 — Python and dependency support matrix + +- Drop Python 3.9; support 3.10–3.13. Update `pyupgrade`/`black`/`ruff` target versions accordingly. +- Expand the CI matrix to 3.10/3.11/3.12/3.13 on ubuntu + macos + windows, with a reduced "full matrix" on `main` and a fast subset on PRs. +- Add a scheduled weekly "latest dependencies" job (unpinned resolve) to catch upstream breakage early. + +### WS-4 — Correctness fixes + +- Fix the `make_tas_archive` filename/path bug (§1.2.1) with a unit test asserting produced filenames. +- Harden `install_pkgdata`: streaming download with `stream=True` + `tqdm`, `timeout`, `raise_for_status`, `os.makedirs(exist_ok=True)`, checksum verification, resume/skip-if-present, and an explicit error (not silent fallback) when a version has no registered dataset. +- Move the version→Zenodo-URL map out of code into a small data file (`stitches/data/zenodo_registry.json` or a `concept DOI` that always resolves to the latest record). +- Audit every `groupby([...])` call site for the pandas 2.x single-key-tuple behavior: [`fx_match.py`](../stitches/fx_match.py:150), [`fx_recipe.py`](../stitches/fx_recipe.py:514), [`make_matching_archive.py`](../stitches/make_matching_archive.py:60), [`make_tas_archive.py`](../stitches/make_tas_archive.py:453). +- Replace `print` diagnostics throughout with the `logging` module and a package-level logger. + +### WS-5 — Code quality and structure + +- Consolidate linting on `ruff` (+`ruff format`) replacing flake8/isort/pyupgrade/pydocstyle; keep `blackdoc`/`nbqa` equivalents or migrate to `ruff`'s notebook support. +- Bump all `.pre-commit-config.yaml` revs and enable `pre-commit autoupdate` on a schedule. +- Add type hints to the public API (`match_neighborhood`, `make_recipe`, `gridded_stitching`, `gmat_stitching`) and introduce `mypy` in non-blocking mode. +- Split the very large [`stitches/fx_recipe.py`](../stitches/fx_recipe.py) (~1100 lines) into `recipe/permute.py`, `recipe/transitions.py`, `recipe/gridded.py`, preserving the public import surface in [`stitches/__init__.py`](../stitches/__init__.py) — a pure-refactor PR that must be output-identical. +- Replace the `fx_*` module naming with descriptive names, keeping deprecation shims for one minor release. + +### WS-6 — Performance + +Only after WS-1 benchmarks exist. Candidate targets, all validated as output-identical: + +- Vectorize the per-group Python loops in [`stitches/fx_match.py`](../stitches/fx_match.py:150) and [`stitches/make_matching_archive.py`](../stitches/make_matching_archive.py:92) (nested `for offset ... for key, d in groupby` is O(window × groups)). +- Avoid repeated `pd.concat` in loops (build lists then concat once — mostly done, but verify [`fx_stitch.py`](../stitches/fx_stitch.py:494)). +- Cache `pangeo_table.csv` / `matching_archive.csv` reads instead of re-reading per call ([`fx_recipe.py`](../stitches/fx_recipe.py:980), [`fx_recipe.py`](../stitches/fx_recipe.py:1126)). +- Consider Parquet instead of CSV for the shipped archives (faster load, smaller download) — output-identical after read. +- Evaluate `dask`/chunked reads in [`gridded_stitching`](../stitches/fx_stitch.py:245) for memory ceilings on large recipes. + +### WS-7 — Repository size and clone speed + +Implemented per [`plans/repo-clone-performance.md`](repo-clone-performance.md): + +- Add `nbstripout` (or `jupytext` paired scripts) to pre-commit so notebook outputs never enter history again. +- Deduplicate image assets; move large tutorial figures to the docs build or an external asset release. +- Decide on history rewrite (`git filter-repo`) vs. non-destructive mitigations (shallow/partial clone guidance, `git gc --aggressive`). + +### WS-8 — Documentation and community files + +- Add `CHANGELOG.md` (Keep a Changelog format) and start recording output-affecting changes explicitly. +- Regenerate the quickstart notebook against the current API; publish executed docs via `nbsphinx` at build time rather than committing rendered PNGs. +- Document the benchmark/regression workflow in [`docs/source/reference/contributing.rst`](../docs/source/reference/contributing.rst). +- Refresh badges and installation instructions in [`README.md`](../README.md); add a "supported Python versions" and "data version" table. +- Update `CITATION.cff` version/date and add a `SECURITY.md`. + +--- + +## 4. Sequencing + +```mermaid +flowchart TD + WS1[WS-1 Baseline + regression harness] --> WS2[WS-2 Packaging] + WS1 --> WS4[WS-4 Correctness fixes] + WS2 --> WS3[WS-3 Python matrix] + WS3 --> WS5[WS-5 Code quality] + WS4 --> WS5 + WS5 --> WS6[WS-6 Performance] + WS1 --> WS7[WS-7 Repo size] + WS6 --> WS8[WS-8 Docs and release] + WS7 --> WS8 +``` + +Ordering rules: + +1. Nothing that can change numerical output merges before WS-1 is green. +2. WS-7 history rewrite, if chosen, happens at a coordinated cut-over point and is announced to collaborators. +3. Release `0.14.0` after WS-2/3/4; release `1.0.0` after WS-5/6 once the API is typed and stable. + +--- + +## 5. Definition of Done + +- `pyproject.toml`-based build; `pip install stitches-emulator` works on Python 3.10–3.13 on all three OSes. +- `pytest` suite runs with zero silent skips; network/slow tests explicitly marked. +- Golden-output invariance suite passes; every intentional output change has a changelog entry citing the defect it corrects. +- Benchmark suite reports no regression beyond agreed thresholds. +- A fresh `git clone` completes in a fraction of the current time, with a documented measurement before/after. +- Docs build clean; changelog, citation, and README all reflect the released version. diff --git a/plans/repo-clone-performance.md b/plans/repo-clone-performance.md new file mode 100644 index 00000000..6753f76d --- /dev/null +++ b/plans/repo-clone-performance.md @@ -0,0 +1,232 @@ +# Why Cloning `stitches` Is Slow — Diagnosis and Solutions + +The checked-out working tree is modest (a dozen Python modules, a handful of small CSVs, a Sphinx site). The clone cost is therefore **almost entirely in `.git`**: object history, not the current snapshot. + +--- + +## 1. Root Causes + +### Cause A — Notebooks committed with embedded base64 outputs (primary) + +Four notebooks contain inline `image/png` payloads: + +| Notebook | Embedded PNG outputs found | +|---|---| +| [`notebooks/stitches-quickstart.ipynb`](../notebooks/stitches-quickstart.ipynb) | 6 | +| [`notebooks/stitches_takehome_GCAMAnnualMeeting2023.ipynb`](../notebooks/stitches_takehome_GCAMAnnualMeeting2023.ipynb) | 10 | +| [`notebooks/stitches_training_GCAMAnnualMeeting2023.ipynb`](../notebooks/stitches_training_GCAMAnnualMeeting2023.ipynb) | 6 | +| [`notebooks/preparing-input-data.ipynb`](../notebooks/preparing-input-data.ipynb) | 1 | + +Why this is disproportionately expensive: + +- Base64 inflates binary PNGs by ~33%. +- The payloads sit on **single enormous JSON lines**, so git's line-oriented delta compression is ineffective. +- Re-executing a notebook changes every image byte-for-byte (matplotlib metadata, font hinting, timestamps), plus `execution_count` and cell ids. Each commit that touches a notebook therefore stores an essentially **new full copy** of every figure in it. +- The training/takehome notebooks were produced with matplotlib 3.5.1 and the quickstart with 3.8.2, which is direct evidence of multiple re-execution generations living in history. + +Net effect: history accumulates N copies of every figure for N notebook commits. + +### Cause B — Package data that was committed and later ignored + +[`.gitignore`](../.gitignore:1) begins with a block of *external data* exclusions: + +``` +stitches/data/tas-data/*.* +stitches/data/temp-data/*.* +stitches/data/*.nc +stitches/data/matching_archive.csv +stitches/data/matching_archive_staggered.csv +stitches/data/pangeo_comparison_table.csv +stitches/data/pangeo_table.csv +stitches/data/*.csv +``` + +Rules that specific are typically written *after* the files caused a problem. If any of those CSV/NetCDF files (the archive is Zenodo record `8367628`, a multi-hundred-MB `data.zip`) were ever committed, `.gitignore` removes them from the *working tree* only — the blobs stay in history forever and are still transferred on clone. + +### Cause C — Duplicated and rendered binary assets + +- `stitches_diagram.jpg` exists three times: [`docs/source/getting-started/stitches_diagram.jpg`](../docs/source/getting-started/stitches_diagram.jpg), [`notebooks/figs/stitches_diagram.jpg`](../notebooks/figs/stitches_diagram.jpg), [`paper/stitches_diagram.jpg`](../paper/stitches_diagram.jpg). +- Committed Sphinx renders: `docs/source/getting-started/output_13_1.png`, `output_16_0.png`, `output_22_0.png`, `output_25_0.png`, `output_40_0.png`, `output_40_1.png` — these are *generated* artifacts that should be produced at docs-build time. +- Additional JPEGs under [`notebooks/figs/`](../notebooks/figs/) (`Tutorial_2023_T_dT_example.jpg`, `Tutorial_2023_tgavex.jpg`) and [`docs/source/images/`](../docs/source/images/). + +Binary assets never delta-compress well, and every revision of them is a full new blob. + +--- + +## 2. Measure Before You Act + +Run these and record the numbers in the PR that implements the fix. + +```bash +# Total repo size and pack breakdown +du -sh .git +git count-objects -vH + +# 20 largest blobs actually reachable in history, with their paths +git rev-list --objects --all \ + | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ + | awk '$1=="blob"' \ + | sort -k3 -n -r \ + | head -20 + +# Size contribution grouped by path +git rev-list --objects --all \ + | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ + | awk '$1=="blob" {print $3, $4}' \ + | sort -k2 \ + | awk '{s[$2]+=$1} END {for (p in s) print s[p], p}' \ + | sort -n -r | head -30 + +# Clone wall-clock baseline (cold) +time git clone --no-local /tmp/stitches-clonetest +``` + +`git-sizer` and `git filter-repo --analyze` both produce a nicer report: + +```bash +brew install git-sizer git-filter-repo +git-sizer --verbose +git filter-repo --analyze # writes .git/filter-repo/analysis/*.txt +``` + +The `analysis/path-all-sizes.txt` output directly confirms or refutes Causes A/B/C with hard numbers. + +--- + +## 3. Solutions + +### 3.1 Immediate relief for users (non-destructive, zero coordination) + +Document these in [`README.md`](../README.md) and the contributor guide: + +```bash +# Blobless clone — full history graph, blobs fetched on demand. Usually the best default. +git clone --filter=blob:none https://github.com/JGCRI/stitches.git + +# Shallow clone — for CI or read-only use +git clone --depth 1 https://github.com/JGCRI/stitches.git + +# Single branch, shallow — smallest +git clone --depth 1 --single-branch --branch main https://github.com/JGCRI/stitches.git +``` + +Also apply `--filter=blob:none` in CI checkouts: + +```yaml +- uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 1 +``` + +This mitigates the symptom immediately without touching history. + +### 3.2 Stop the bleeding (required, do this first) + +1. **Strip notebook outputs on commit.** Add to [`.pre-commit-config.yaml`](../.pre-commit-config.yaml): + + ```yaml + - repo: https://github.com/kynan/nbstripout + rev: 0.7.1 + hooks: + - id: nbstripout + ``` + + Optionally add a `.gitattributes` filter as a second line of defense: + + ``` + *.ipynb filter=nbstripout + ``` + +2. **Guard against large files.** Add `check-added-large-files` to the existing `pre-commit-hooks` block: + + ```yaml + - id: check-added-large-files + args: ['--maxkb=512'] + ``` + +3. **Stop committing generated docs images.** Delete `docs/source/getting-started/output_*.png` and let `nbsphinx` execute the notebook during the docs build (`nbsphinx_execute = "always"` with cached data), or generate them into `docs/_build/`. + +4. **Deduplicate `stitches_diagram.jpg`** to one canonical location (e.g. `docs/source/images/`) and reference it from the notebook and paper. + +Trade-off to accept: stripped notebooks show no output on GitHub's static renderer. Mitigate by publishing executed notebooks in the hosted docs (already wired via `nbsphinx`) and linking to them from the README. + +### 3.3 Structural options for the notebooks + +| Option | Clone cost | GitHub preview | Effort | +|---|---|---|---| +| `nbstripout` (outputs removed) | Low | No outputs shown | Low | +| `jupytext` paired `.py:percent` + generated `.ipynb` untracked | Lowest | No notebook in repo | Medium | +| Keep outputs but move figures to external files referenced by path | Medium | Outputs shown | Medium | +| Keep as-is | High | Outputs shown | None | + +Recommendation: `nbstripout` now; consider `jupytext` pairing for the two GCAM-meeting notebooks, which are historical training artifacts and arguably belong in a tagged release asset rather than `main`. + +### 3.4 History rewrite (optional, destructive, highest payoff) + +Only worth doing if §2 shows that historical blobs dominate. This is the only way to actually shrink an existing clone. + +```bash +# Work on a fresh mirror, never your working clone +git clone --mirror https://github.com/JGCRI/stitches.git stitches-mirror +cd stitches-mirror + +# Drop historical package data and generated docs renders +git filter-repo \ + --path-glob 'stitches/data/tas-data/*' \ + --path-glob 'stitches/data/temp-data/*' \ + --path-glob 'stitches/data/*.nc' \ + --path-glob 'stitches/data/matching_archive*.csv' \ + --path-glob 'stitches/data/pangeo_*table.csv' \ + --path-glob 'docs/source/getting-started/output_*.png' \ + --invert-paths + +# Strip notebook outputs from every historical revision +git filter-repo --force \ + --path-glob '*.ipynb' \ + --blob-callback ' +import json +try: + nb = json.loads(blob.data) +except Exception: + pass +else: + if isinstance(nb, dict) and "cells" in nb: + for c in nb["cells"]: + c["outputs"] = [] + c["execution_count"] = None + blob.data = json.dumps(nb, indent=1).encode() +' + +git reflog expire --expire=now --all +git gc --prune=now --aggressive +``` + +Consequences that must be planned for: + +- **Every commit SHA changes.** All open PRs must be rebased or recreated; all forks must re-clone or re-base. +- Zenodo DOI archives and the JOSS paper reference specific commits/tags — verify tags survive the rewrite (`git filter-repo` preserves them but re-points them) and keep a **read-only archived mirror** of the pre-rewrite history for citation integrity. +- Requires a force-push to a protected branch: temporarily lift protection, announce a freeze window, then restore. + +Pre-flight checklist: + +- [ ] §2 measurements recorded, showing the expected savings +- [ ] Archived mirror pushed to a `stitches-history-archive` repository +- [ ] All maintainers notified with a freeze window and re-clone instructions +- [ ] Open PR inventory captured +- [ ] Tags and releases verified post-rewrite on the mirror before force-push +- [ ] Post-rewrite clone time re-measured and recorded + +### 3.5 If large files must live in the repo + +Prefer *not* to. The package already downloads science data from Zenodo at runtime via [`stitches/install_pkgdata.py`](../stitches/install_pkgdata.py), which is the correct pattern. If any binary must be versioned, use Git LFS or a GitHub Release asset rather than a normal blob. + +--- + +## 4. Recommended Plan of Record + +1. Measure (§2) and publish the numbers. +2. Land the preventive hooks and asset cleanup (§3.2) — safe, immediate, no coordination. +3. Document `--filter=blob:none` for users and switch CI to filtered/shallow checkout (§3.1). +4. Decide on the rewrite (§3.4) based on the §2 evidence; if the historical blobs are under ~50 MB, skip it and rely on partial clone. +5. Re-measure clone time and record the before/after in `CHANGELOG.md`. From a28fdfb995f96c2976115577f5657a09f9dc428a Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 15:52:10 -0400 Subject: [PATCH 02/14] docs(plans): record measured git history sizes Adds a measurements section with concrete numbers from branch release/v1: - .git is 288 MB (274 MiB pack) against a ~2 MB working tree - 784.1 MB of uncompressed blobs across all history - 722.6 MB (92%) belongs to paths that no longer exist in HEAD Largest contributors are deleted stitched NetCDF outputs under notebooks/quickstart-ncs (188 MB), historical stitches/data package data (346 MB), and rendered dev notebooks under notebooks/stitches_dev (127 MB). notebooks/stitches-quickstart.ipynb has accumulated 38.5 MB over 31 revisions because outputs are committed. This evidence justifies the history rewrite option, so the filter-repo command was updated with the confirmed offending paths plus a verification step asserting HEAD is unchanged. --- plans/repo-clone-performance.md | 73 ++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/plans/repo-clone-performance.md b/plans/repo-clone-performance.md index 6753f76d..d74cc3fd 100644 --- a/plans/repo-clone-performance.md +++ b/plans/repo-clone-performance.md @@ -4,6 +4,61 @@ The checked-out working tree is modest (a dozen Python modules, a handful of sma --- +## 0. Measured Results (2026-08-28, branch `release/v1`) + +``` +working tree ~2 MB +.git 288 MB (size-pack 274.44 MiB, 5702 objects in 1 pack) +total blobs 784.1 MB uncompressed across all history +``` + +Blob bytes grouped by category: + +| Uncompressed | Category | In HEAD? | +|---:|---|---| +| 346.2 MB | `stitches/data/**` package data | No — `.gitignore`d | +| 188.3 MB | `notebooks/quickstart-ncs/*.nc` | No — deleted | +| 127.2 MB | `notebooks/stitches_dev/**` (HTML + CSV) | No — deleted | +| 82.9 MB | `*.ipynb` with embedded outputs | Partly | +| 20.2 MB | images / PDF | Partly | +| 16.3 MB | everything else | Mostly | +| 2.9 MB | HTML | No | + +**722.6 MB of 784.1 MB (92%) is blobs for paths that no longer exist in `HEAD`.** + +Worst individual offenders: + +| Size | Revs | Path | +|---:|---:|---| +| 94.16 MB | 1 | `notebooks/quickstart-ncs/stitched_CanESM5_tas_ssp245~r1i1p1f1~1.nc` | +| 94.16 MB | 1 | `notebooks/quickstart-ncs/stitched_CanESM5_pr_ssp245~r1i1p1f1~1.nc` | +| 77.10 MB | 1 | `stitches/data/pangeo_comparison_table.csv` | +| 56.47 MB | 5 | `stitches/data/pangeo_table.csv` | +| 49.89 MB | 3 | `stitches/data/matching_archive_staggered.csv` | +| 41.93 MB | 1 | `notebooks/stitches_dev/inputs/main_raw_pasted_tgav_anomaly_all_pangeo_list_models.csv` | +| 38.52 MB | **31** | `notebooks/stitches-quickstart.ipynb` | +| 28.33 MB | 12 | `notebooks/stitches_dev/Notebook6_throwout_Duplicates-across-ensemble-members.html` | +| 21.63 MB | 8 | `stitches/data/matching_archive.csv` | +| 20.53 MB | 2 | `stitches/data/created_data/main_tgav_all_pangeo_list_models.csv` | +| 11.51 MB | 3 | `stitches/data/tas-data/ACCESS-ESM1-5_tas.csv` | +| 10.86 MB | 1 | `notebooks/figs/Tutorial_2023_tgavex.tiff` | +| 10.14 MB | 8 | `notebooks/GCAM_AnnualMeeting2023.ipynb` | +| 7.85 MB | 2 | `stitches/data/tas_values.pkl` | + +Reproduce with: + +```bash +git rev-list --objects --all \ + | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \ + | awk '$1=="blob" {print $3, $4}' | sort -k2 \ + | awk '{s[$2]+=$1; n[$2]++} END {for (p in s) printf "%12.2f MB %4d rev %s\n", s[p]/1048576, n[p], p}' \ + | sort -rn | head -30 +``` + +**Conclusion: a history rewrite (§3.4) is clearly justified.** Removing the deleted-path blobs alone should reduce the clone by roughly an order of magnitude. Notebook output stripping (§3.2) is still required to stop the problem recurring — `stitches-quickstart.ipynb` alone has accumulated 38.5 MB across 31 revisions of an ~1 MB file. + +--- + ## 1. Root Causes ### Cause A — Notebooks committed with embedded base64 outputs (primary) @@ -26,7 +81,9 @@ Why this is disproportionately expensive: Net effect: history accumulates N copies of every figure for N notebook commits. -### Cause B — Package data that was committed and later ignored +### Cause B — Package data and generated artifacts committed and later deleted (largest total) + +Confirmed by §0: 346.2 MB of `stitches/data/**`, 188.3 MB of stitched NetCDF outputs under `notebooks/quickstart-ncs/`, and 127.2 MB of rendered development notebooks under `notebooks/stitches_dev/`. None of these paths exist in `HEAD`. [`.gitignore`](../.gitignore:1) begins with a block of *external data* exclusions: @@ -89,6 +146,8 @@ git-sizer --verbose git filter-repo --analyze # writes .git/filter-repo/analysis/*.txt ``` +Note: `git filter-repo --analyze` refuses to run in a repo with uncommitted changes and, like all `filter-repo` invocations, expects a fresh clone. Run it against a throwaway `--mirror` clone. + The `analysis/path-all-sizes.txt` output directly confirms or refutes Causes A/B/C with hard numbers. --- @@ -171,16 +230,26 @@ Only worth doing if §2 shows that historical blobs dominate. This is the only w git clone --mirror https://github.com/JGCRI/stitches.git stitches-mirror cd stitches-mirror -# Drop historical package data and generated docs renders +# Drop historical package data, stitched outputs, and dev notebook renders. +# Paths below are the confirmed offenders from section 0; none exist in HEAD, +# so removing them cannot affect the current tree. git filter-repo \ + --path 'notebooks/quickstart-ncs' \ + --path 'notebooks/stitches_dev' \ + --path 'stitches/data/created_data' \ --path-glob 'stitches/data/tas-data/*' \ --path-glob 'stitches/data/temp-data/*' \ --path-glob 'stitches/data/*.nc' \ + --path-glob 'stitches/data/*.pkl' \ --path-glob 'stitches/data/matching_archive*.csv' \ --path-glob 'stitches/data/pangeo_*table.csv' \ + --path-glob '*.tiff' \ --path-glob 'docs/source/getting-started/output_*.png' \ --invert-paths +# Verify nothing in HEAD was removed +git diff --stat HEAD # expect: empty + # Strip notebook outputs from every historical revision git filter-repo --force \ --path-glob '*.ipynb' \ From cef4df5b0c301b2fbf224fe1482d437829e4c183 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 15:59:31 -0400 Subject: [PATCH 03/14] test: replace RUN='ci' vacuous passes with explicit pytest markers The suite had two structural problems: 1. tests/test_pangeo.py and tests/test_stitch.py gated their real assertions behind a hardcoded 'RUN = "ci"' class attribute whose false branch ran 'self.assertEqual(0, 0)'. In CI these tests reported as PASSING while exercising no code at all, so the gridded stitching and Pangeo code paths were effectively untested and any regression there was invisible. 2. tests/conftest.py installed the full Zenodo package data in a session-scoped autouse fixture, so every pytest invocation -- even a single pure unit test -- downloaded hundreds of megabytes. Changes: - Add pytest.ini registering markers (network, slow, package_data, regression, benchmark) with --strict-markers so typos fail loudly. - Rewrite tests/conftest.py: package data becomes an explicit opt-in fixture instead of autouse; add --network/--slow/--package-data flags with matching STITCHES_TEST_* env vars; add pytest_collection_modifyitems so gated tests skip with a visible reason rather than passing vacuously. Also adds the --update-golden flag used by the forthcoming regression suite, plus read_example/example_data_dir fixtures for the offline tier. - Convert both test modules from unittest.TestCase to pytest functions, split the monolithic test methods into single-behavior tests, and mark them by the capability they actually need. gmat_stitching is package_data; gridded stitching and Pangeo are network+slow. Gridded tests now write to tmp_path instead of the repo root and close datasets via context managers. - Strengthen a few assertions along the way: the row-order-invariance test now compares full frames rather than two scalar cells, and the Pangeo table test checks for required columns. - gitignore .venv/, .vscode/, and .benchmarks/. The offline tier now runs in ~2s with 15 passed / 13 visibly skipped, where previously it required a large download to run at all. --- .gitignore | 11 ++ pytest.ini | 23 +++ tests/conftest.py | 169 ++++++++++++++++++-- tests/test_pangeo.py | 74 +++++---- tests/test_stitch.py | 369 +++++++++++++++++++++++-------------------- 5 files changed, 431 insertions(+), 215 deletions(-) create mode 100644 pytest.ini diff --git a/.gitignore b/.gitignore index 2a4239d2..cd16f566 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,17 @@ tests/.DS_Store # pycharm .idea +# vscode +.vscode + +# local development virtualenvs +.venv/ +venv/ +env/ + +# pytest-benchmark results +.benchmarks/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..b6fe44c3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,23 @@ +[pytest] +# Test discovery +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +# Benchmarks live in benchmarks/ and are excluded from the default run because +# they measure performance rather than correctness. Run them explicitly with: +# pytest benchmarks --benchmark-only +addopts = + --strict-markers + -ra + +markers = + network: requires internet access (Pangeo catalog, Zenodo data download). Skipped unless --network is passed or STITCHES_TEST_NETWORK=1 is set. + slow: long-running test (>30s wall clock). Skipped unless --slow is passed or STITCHES_TEST_SLOW=1 is set. + package_data: requires the full Zenodo-minted package data to be installed. + regression: golden-output invariance test; asserts scientific outputs are unchanged. + benchmark: performance measurement, not a correctness assertion. + +filterwarnings = + default diff --git a/tests/conftest.py b/tests/conftest.py index fdb3d4a7..109d1abc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,18 +1,169 @@ -"""This module contains pytest fixtures and configurations for testing the stitches package.""" +"""Pytest fixtures and configuration for the stitches test suite. +Design goals: + +* **No implicit network access.** The previous version of this module installed + the full Zenodo-minted package data in a session-scoped ``autouse`` fixture, + which meant every ``pytest`` invocation -- including a run of a single pure + unit test -- downloaded hundreds of megabytes. Package data is now an + explicit, opt-in fixture (``package_data``) that individual tests request. +* **No silent skips.** Tests that need the network or the full data archive are + marked (``network``, ``package_data``, ``slow``) and are deselected with a + visible skip reason rather than passing vacuously. + +Opt-in mechanisms, in precedence order: + +=================== ========================= ================================ +Capability CLI flag Environment variable +=================== ========================= ================================ +Network access ``--network`` ``STITCHES_TEST_NETWORK=1`` +Long-running tests ``--slow`` ``STITCHES_TEST_SLOW=1`` +Full package data ``--package-data`` ``STITCHES_TEST_PACKAGE_DATA=1`` +=================== ========================= ================================ + +``--package-data`` implies ``--network`` on first use, since the archive must be +downloaded before it can be used. +""" + +import os +from importlib import resources +from pathlib import Path + +import pandas as pd import pytest -import stitches +# --------------------------------------------------------------------------- +# Command line options +# --------------------------------------------------------------------------- + +#: Maps a marker name to its ``(cli flag, environment variable)`` pair. +_OPT_IN_MARKERS = { + "network": ("--network", "STITCHES_TEST_NETWORK"), + "slow": ("--slow", "STITCHES_TEST_SLOW"), + "package_data": ("--package-data", "STITCHES_TEST_PACKAGE_DATA"), +} + + +def pytest_addoption(parser): + """Register the opt-in flags for capability-gated tests.""" + group = parser.getgroup("stitches") + group.addoption( + "--network", + action="store_true", + default=False, + help="Run tests marked 'network' that require internet access.", + ) + group.addoption( + "--slow", + action="store_true", + default=False, + help="Run tests marked 'slow' (>30s wall clock).", + ) + group.addoption( + "--package-data", + action="store_true", + default=False, + help=( + "Run tests marked 'package_data', downloading the Zenodo-minted " + "archive if it is not already present. Implies --network." + ), + ) + group.addoption( + "--update-golden", + action="store_true", + default=False, + help=( + "Regenerate golden regression artifacts from the current code " + "instead of asserting against them. Use only when an output change " + "is intentional, and record the justification in CHANGELOG.md." + ), + ) + + +def _enabled(config, marker): + """Return True when the capability behind ``marker`` has been opted into.""" + flag, env_var = _OPT_IN_MARKERS[marker] + if config.getoption(flag.lstrip("-").replace("-", "_")): + return True + if os.environ.get(env_var, "").strip().lower() in {"1", "true", "yes"}: + return True + # Downloading package data necessarily requires the network, so enabling + # package data also enables the network capability. + if marker == "network" and _enabled(config, "package_data"): + return True + return False + + +def pytest_collection_modifyitems(config, items): + """Skip capability-gated tests unless the capability was opted into.""" + for marker in _OPT_IN_MARKERS: + if _enabled(config, marker): + continue + flag, env_var = _OPT_IN_MARKERS[marker] + skip = pytest.mark.skip( + reason=f"needs {marker!r}: pass {flag} or set {env_var}=1 to run" + ) + for item in items: + if marker in item.keywords: + item.add_marker(skip) + +# --------------------------------------------------------------------------- +# Data fixtures +# --------------------------------------------------------------------------- -@pytest.fixture(scope="session", autouse=True) -def setup_package_data(): + +@pytest.fixture(scope="session") +def example_data_dir(): + """Return the directory holding the small committed example CSV fixtures. + + These files ship inside the package and require no download, so they back + the offline tier of the test suite. + """ + return Path(str(resources.files("stitches") / "data" / "example")) + + +@pytest.fixture(scope="session") +def read_example(example_data_dir): + """Return a loader for a committed example CSV by stem name. + + Usage:: + + def test_something(read_example): + target = read_example("test-target_dat") """ - Set up the package data for testing. - This fixture is automatically used in tests that require the package data. - It installs the package data and prepares the testing environment. + def _read(name): + stem = name[:-4] if name.endswith(".csv") else name + path = example_data_dir / f"{stem}.csv" + if not path.is_file(): + raise FileNotFoundError(f"No example fixture named {stem!r} at {path}") + return pd.read_csv(path) + + return _read + + +@pytest.fixture(scope="session") +def package_data(): + """Ensure the full Zenodo-minted package data is installed. + + Tests requesting this fixture must also carry the ``package_data`` marker so + that they are deselected when the capability is not opted into. The download + is performed at most once per session and is skipped when the data already + appears to be present. """ - stitches.install_package_data() + import stitches + + data_dir = Path(str(resources.files("stitches") / "data")) + sentinel = data_dir / "matching_archive.csv" + + if not sentinel.is_file(): + stitches.install_package_data() + + if not sentinel.is_file(): + pytest.fail( + f"package data installation did not produce {sentinel}; " + "cannot run package_data-marked tests" + ) - return None + return data_dir diff --git a/tests/test_pangeo.py b/tests/test_pangeo.py index a20f7932..6d936882 100644 --- a/tests/test_pangeo.py +++ b/tests/test_pangeo.py @@ -1,45 +1,43 @@ -import unittest +"""Tests for the Pangeo CMIP6 catalog interface. + +These tests reach out to the live Pangeo catalog on Google Cloud Storage and are +therefore marked ``network``. Previously they were gated behind a ``RUN = "ci"`` +class attribute whose false branch asserted ``0 == 0``, meaning they reported as +*passing* in CI while exercising nothing. They now skip visibly instead; run +them with ``pytest --network``. +""" import pandas as pd +import pytest import xarray as xr from stitches.fx_pangeo import fetch_nc, fetch_pangeo_table -class TestPangeo(unittest.TestCase): - """ - A test case for Pangeo-related functions. - - This test class is used to run tests for functions that interact with the Pangeo data archive. - It includes a flag to run tests for continuous integration or for all cases. - """ - - RUN = "ci" - - def test_pangeo_fn(self): - """Test Pangeo-related functions for continuous integration or full cases.""" - if TestPangeo.RUN == "ci": - self.assertEqual(0, 0) - else: - # Get the table of all pangeo contents as a data frame - ptable = fetch_pangeo_table() - self.assertEqual( - type(ptable), - pd.core.frame.DataFrame, - "fetch_pangeo_table did not return a data frame", - ) - - # Select a single files from the pangeo cataog to import - import_this = ptable.loc[ - (ptable["table_id"] == "Amon") - & (ptable["activity_id"] == "ScenarioMIP") - ].reset_index(drop=True) - path = import_this["zstore"][0] - out = fetch_nc(path) - self.assertEqual( - type(out), xr.core.dataset.Dataset, "problem with fetch_nc" - ) - - -if __name__ == "__main__": - unittest.main() +@pytest.mark.network +def test_fetch_pangeo_table_returns_dataframe(): + """`fetch_pangeo_table` returns a non-empty DataFrame with expected columns.""" + ptable = fetch_pangeo_table() + + assert isinstance(ptable, pd.DataFrame), "fetch_pangeo_table did not return a DataFrame" + assert len(ptable) > 0, "Pangeo table is empty" + + for column in ("zstore", "table_id", "activity_id", "source_id", "variable_id"): + assert column in ptable.columns, f"Pangeo table missing column {column!r}" + + +@pytest.mark.network +@pytest.mark.slow +def test_fetch_nc_returns_dataset(): + """`fetch_nc` opens a single zarr store from the catalog as an xarray Dataset.""" + ptable = fetch_pangeo_table() + + import_this = ptable.loc[ + (ptable["table_id"] == "Amon") & (ptable["activity_id"] == "ScenarioMIP") + ].reset_index(drop=True) + assert len(import_this) > 0, "no Amon/ScenarioMIP entries found in the Pangeo table" + + out = fetch_nc(import_this["zstore"][0]) + + assert isinstance(out, xr.Dataset), "problem with fetch_nc" + assert "time" in out.dims diff --git a/tests/test_stitch.py b/tests/test_stitch.py index 2f38bb77..29d010a2 100644 --- a/tests/test_stitch.py +++ b/tests/test_stitch.py @@ -1,8 +1,22 @@ -import os -import unittest +"""Tests for the stitching functions. + +Capability notes: + +* ``find_var_cols`` / ``find_zfiles`` are pure DataFrame helpers and run offline. +* ``gmat_stitching`` reads the per-model ``tas-data`` CSVs from the installed + package data, so it is marked ``package_data``. +* ``gridded_stitching`` and ``internal_stitch`` pull zarr stores from Pangeo and + write NetCDF, so they are marked ``network`` and ``slow``. + +The previous version of this module gated the gridded tests behind a +``RUN = "ci"`` class attribute whose false branch asserted ``0 == 0``. That made +them report as passing in CI while exercising nothing. They now skip visibly; +run them with ``pytest --network --slow``. +""" import numpy as np import pandas as pd +import pytest import xarray as xr from stitches.fx_pangeo import fetch_nc @@ -15,172 +29,191 @@ ) from stitches.fx_util import nrow +# An example recipe used across the stitching tests. +MY_RP = pd.DataFrame( + data={ + "target_start_yr": [1850, 1859], + "target_end_yr": [1858, 1867], + "archive_experiment": ["historical", "historical"], + "archive_variable": ["tas", "tas"], + "archive_model": ["BCC-CSM2-MR", "BCC-CSM2-MR"], + "archive_ensemble": ["r1i1p1f1", "r1i1p1f1"], + "stitching_id": ["ssp245~r1i1p1f1~1", "ssp245~r1i1p1f1~1"], + "archive_start_yr": [1859, 1886], + "archive_end_yr": [1867, 1894], + "tas_file": [ + "gs://cmip6/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/Amon/tas/gn/v20181126/", + "gs://cmip6/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/Amon/tas/gn/v20181126/", + ], + } +) + + +@pytest.fixture +def recipe(): + """Return a fresh copy of the example recipe so tests cannot mutate shared state.""" + return MY_RP.copy() + + +# --------------------------------------------------------------------------- +# Offline helpers +# --------------------------------------------------------------------------- + + +def test_find_var_cols(): + """`find_var_cols` identifies columns whose names end in ``_file``.""" + o = pd.DataFrame(data={"tas": [1, 2], "col2": [3, 4]}) + assert len(find_var_cols(o)) == 0 + + o = pd.DataFrame(data={"tas_file": [1, 2], "col2": [3, 4]}) + assert find_var_cols(o) == ["tas"] + + o = pd.DataFrame(data={"tas_file": [1, 2], "col2": [3, 4], "fake_file": [1, 2]}) + assert len(find_var_cols(o)) == 2 + + +def test_find_zfiles_returns_empty_without_file_columns(): + """`find_zfiles` returns nothing when the frame has no ``_file`` columns.""" + d = pd.DataFrame(data={"tas": [1, 2], "col2": [3, 4], "year": [1, 2]}) + assert len(find_zfiles(d)) == 0 -class TestStitch(unittest.TestCase): - """ - Unit tests for stitching functions in the `stitches` package. - - This class provides a set of tests to ensure the correct functionality - of the stitching functions, which are used to combine different climate - model outputs into a single coherent dataset. - """ - - RUN = "ci" - - # This is an example recipe that will be used to test the stitching functions - MY_RP = pd.DataFrame( - data={ - "target_start_yr": [1850, 1859], - "target_end_yr": [1858, 1867], - "archive_experiment": ["historical", "historical"], - "archive_variable": ["tas", "tas"], - "archive_model": ["BCC-CSM2-MR", "BCC-CSM2-MR"], - "archive_ensemble": ["r1i1p1f1", "r1i1p1f1"], - "stitching_id": ["ssp245~r1i1p1f1~1", "ssp245~r1i1p1f1~1"], - "archive_start_yr": [1859, 1886], - "archive_end_yr": [1867, 1894], - "tas_file": [ - "gs://cmip6/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/Amon/tas/gn/v20181126/", - "gs://cmip6/CMIP6/CMIP/BCC/BCC-CSM2-MR/historical/r1i1p1f1/Amon/tas/gn/v20181126/", - ], - } + +def test_find_zfiles_collects_paths(): + """`find_zfiles` returns an ndarray of the referenced file paths.""" + d = pd.DataFrame( + data={"tas_file": ["file1.csv", "file2.csv"], "col2": [3, 4], "year": [1, 2]} ) + file_list = find_zfiles(d) + + assert isinstance(file_list, np.ndarray) + assert len(file_list) == 2 + + +def test_find_zfiles_deduplicates(): + """`find_zfiles` collapses repeated paths so each store is fetched once.""" + d = pd.DataFrame( + data={"tas_file": ["file1.csv", "file1.csv"], "col2": [3, 4], "year": [1, 2]} + ) + file_list = find_zfiles(d) + + assert len(file_list) == len(np.unique(file_list)) + assert len(file_list) != nrow(d) + + +# --------------------------------------------------------------------------- +# Global-mean stitching (needs the installed tas-data archive) +# --------------------------------------------------------------------------- + + +@pytest.mark.package_data +def test_gmat_stitching_shape(package_data, recipe): + """`gmat_stitching` returns one row per target year covered by the recipe.""" + out = gmat_stitching(recipe) + + assert isinstance(out, pd.DataFrame) + + time_steps = max(recipe["target_end_yr"]) - min(recipe["target_start_yr"]) + 1 + assert nrow(out) == time_steps + + +@pytest.mark.package_data +def test_gmat_stitching_is_row_order_invariant(package_data, recipe): + """Reversing the recipe row order must not change the stitched result.""" + out = gmat_stitching(recipe) + + reverse = recipe.iloc[::-1] + out2 = gmat_stitching(reverse) + + assert out.shape == out2.shape + pd.testing.assert_frame_equal( + out.sort_values("year").reset_index(drop=True), + out2.sort_values("year").reset_index(drop=True), + ) + + +@pytest.mark.package_data +def test_gmat_stitching_ignores_tas_file_column(package_data, recipe): + """The ``tas_file`` values are unused by `gmat_stitching`, which reads local data.""" + recipe["tas_file"] = ["fake.nc", "fake.nc"] + out = gmat_stitching(recipe) + + assert isinstance(out, pd.DataFrame) + + +@pytest.mark.package_data +@pytest.mark.parametrize("missing", ["tas_file", "target_start_yr"]) +def test_gmat_stitching_requires_columns(package_data, recipe, missing): + """Dropping a required recipe column raises `KeyError`.""" + with pytest.raises(KeyError): + gmat_stitching(recipe.drop(columns=missing)) + + +@pytest.mark.package_data +def test_gmat_stitching_rejects_unknown_model(package_data, recipe): + """A model absent from the archive raises `IndexError`.""" + recipe["archive_model"] = ["fake", "fake"] + with pytest.raises(IndexError): + gmat_stitching(recipe) + + +# --------------------------------------------------------------------------- +# Gridded stitching (needs Pangeo) +# --------------------------------------------------------------------------- + + +def _expected_monthly_steps(rp): + """Return the number of monthly time steps a recipe should produce.""" + return 12 * (max(rp["target_end_yr"]) - min(rp["target_start_yr"])) + 12 + + +@pytest.mark.network +@pytest.mark.slow +def test_internal_stitch_time_length(recipe): + """`internal_stitch` produces the expected number of monthly time steps.""" + file_list = find_zfiles(recipe) + data_list = list(map(fetch_nc, file_list)) + rslt = internal_stitch(recipe, data_list, file_list) + + assert len(rslt["tas"]["time"]) == _expected_monthly_steps(recipe) + + +@pytest.mark.network +@pytest.mark.slow +def test_gridded_stitching_writes_dataset(tmp_path, recipe): + """`gridded_stitching` writes a NetCDF file with the expected time axis.""" + out = gridded_stitching(str(tmp_path), recipe) + + with xr.open_dataset(out[0]) as data: + assert isinstance(data, xr.Dataset) + assert len(data["time"]) == _expected_monthly_steps(recipe) + + +@pytest.mark.network +@pytest.mark.slow +def test_gridded_stitching_is_row_order_invariant(tmp_path, recipe): + """Reversing the recipe row order must not change the stitched time axis.""" + forward = gridded_stitching(str(tmp_path / "fwd"), recipe) + with xr.open_dataset(forward[0]) as data: + time1 = data["tas"]["time"].values + + reverse = gridded_stitching(str(tmp_path / "rev"), recipe.iloc[::-1]) + with xr.open_dataset(reverse[0]) as data2: + time2 = data2["tas"]["time"].values + + assert max(time1 - time2) == 0 + + +@pytest.mark.network +@pytest.mark.slow +def test_gridded_stitching_rejects_bad_output_dir(recipe): + """A nonexistent output directory raises `TypeError`.""" + with pytest.raises(TypeError): + gridded_stitching("fake", recipe) + - def test_find_var_cols(self): - """Test the `find_var_cols` function for identifying variable columns.""" - o = pd.DataFrame(data={"tas": [1, 2], "col2": [3, 4]}) - self.assertEqual(len(find_var_cols(o)), 0) - - o = pd.DataFrame(data={"tas_file": [1, 2], "col2": [3, 4]}) - self.assertEqual(find_var_cols(o), ["tas"]) - - o = pd.DataFrame(data={"tas_file": [1, 2], "col2": [3, 4], "fake_file": [1, 2]}) - self.assertEqual(len(find_var_cols(o)), 2) - - def test_find_zfiles(self): - """ - Test the `find_zfiles` function to ensure it correctly identifies zipped file paths. - - This test verifies that the `find_zfiles` function correctly identifies file paths - for zipped files within a given DataFrame. - """ - d = pd.DataFrame(data={"tas": [1, 2], "col2": [3, 4], "year": [1, 2]}) - self.assertEqual(len(find_zfiles(d)), 0) - - d = pd.DataFrame( - data={ - "tas_file": ["file1.csv", "file2.csv"], - "col2": [3, 4], - "year": [1, 2], - } - ) - file_list = find_zfiles(d) - self.assertEqual(type(file_list), np.ndarray) - self.assertEqual(len(file_list), 2) - - d = pd.DataFrame( - data={ - "tas_file": ["file1.csv", "file1.csv"], - "col2": [3, 4], - "year": [1, 2], - } - ) - file_list = find_zfiles(d) - self.assertEqual(len(file_list), len(np.unique(file_list))) - self.assertTrue(len(file_list) != nrow(d)) - - def test_gmat_stitching(self): - """ - Test the output returned by `gmat_stitching`. - - This test checks the type and structure of the output to ensure it meets expected formats. - """ - - out = gmat_stitching(self.MY_RP) - - self.assertEqual(type(out), pd.core.frame.DataFrame) - - time_steps = ( - max(self.MY_RP["target_end_yr"]) - min(self.MY_RP["target_start_yr"]) + 1 - ) - self.assertEqual(nrow(out), time_steps) - - # If the recipe is read in backwards, it shouldn't matter. The output should be the same. - reverse = self.MY_RP.copy() - reverse = reverse.iloc[::-1] - out2 = gmat_stitching(reverse) - self.assertEqual(out.shape, out2.shape) - self.assertEqual(out["year"][0], out2["year"][0]) - self.assertEqual(out["value"][6], out["value"][6]) - - # Manipulate the recipe, sometimes it will be fine other times it will throw an error. - rp = self.MY_RP.copy() - - rp["tas_file"] = ["fake.nc", "fake.nc"] - out = gmat_stitching(rp) - self.assertEqual(type(out), pd.core.frame.DataFrame) - - # If the recpie is missing a column the stitching function should fail. - with self.assertRaises(KeyError): - gmat_stitching(rp.drop("tas_file")) - - with self.assertRaises(KeyError): - gmat_stitching(rp.drop("target_start_yr")) - - with self.assertRaises(IndexError): - rp["archive_model"] = ["fake", "fake"] - gmat_stitching(rp) - - def test_gridded_related(self): - """ - Test functions related to gridded data stitching. - - This test suite covers the functionality of gridded data stitching, - ensuring that the output is consistent and errors are raised when - expected. - """ - if TestStitch.RUN == "ci": - self.assertEqual(0, 0) - else: - # Set up the elements required for internal_stitch - file_list = find_zfiles(self.MY_RP) - data_list = list(map(fetch_nc, file_list)) - rslt = internal_stitch(self.MY_RP, data_list, file_list) - - time_steps = ( - 12 - * ( - max(self.MY_RP["target_end_yr"]) - - min(self.MY_RP["target_start_yr"]) - ) - + 12 - ) - self.assertEqual(len(rslt["tas"]["time"]), time_steps) - - # Now do the stitching - out = gridded_stitching(".", self.MY_RP) - data = xr.open_dataset(out[0]) - time1 = data["tas"]["time"].values - self.assertEqual(type(data), xr.core.dataset.Dataset) - self.assertEqual(len(data["time"]), time_steps) - os.remove(out[0]) - - # If the recipe is read in backwards, it shouldn't matter the output should be the same. - reverse = self.MY_RP.copy() - reverse = reverse.iloc[::-1] - out = gridded_stitching(".", reverse) - data2 = xr.open_dataset(out[0]) - time2 = data2["tas"]["time"].values - os.remove(out[0]) - self.assertEqual(max(time1 - time2), 0) - - # Manipulate the recipe, sometimes it will be fine other times it will throw an error. - rp = self.MY_RP.copy() - with self.assertRaises(TypeError): - gridded_stitching("fake", rp) - with self.assertRaises(KeyError): - gridded_stitching(".", rp.drop("tas_file")) - - -if __name__ == "__main__": - unittest.main() +@pytest.mark.network +@pytest.mark.slow +def test_gridded_stitching_requires_file_column(tmp_path, recipe): + """Dropping ``tas_file`` raises `KeyError`.""" + with pytest.raises(KeyError): + gridded_stitching(str(tmp_path), recipe.drop(columns="tas_file")) From 67a979265496273f74599d484125718a9ca5940d Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:04:22 -0400 Subject: [PATCH 04/14] feat: add explicit seed parameters to randomized entry points Reproducibility was previously only reachable through permute_stitching_recipes(testing=True), which hardcoded random_state=1. Overloading a 'testing' flag to mean 'be deterministic' is awkward for users who legitimately need reproducible recipes, and it also makes the golden-output regression suite depend on a flag whose name implies other behavior. Adds an optional 'seed' parameter to: - shuffle_function(dt, seed=None) - permute_stitching_recipes(..., seed=None) - make_recipe(..., seed=None) Backward compatibility is exact and deliberate: - seed=None with testing=False -> random_state=None, i.e. identical to the previous unseeded call, so existing nondeterministic behavior is unchanged. - seed=None with testing=True -> random_state=1, the previous testing branch. - an explicit seed takes precedence over testing. The seed is applied per groupby group rather than to one shared generator. This is intentional: it reproduces the original random_state=1 semantics bit for bit. Using a single shared generator would be cleaner but would change published outputs, which is out of scope here. Also collapses the duplicated if/else in make_recipe that called permute_stitching_recipes twice with only the testing flag differing. Adds tests/test_seed.py (13 tests) pinning the compatibility contract, including test_permute_seed_matches_legacy_testing_flag which asserts seed=1 and testing=True produce identical frames. No outputs change. --- stitches/fx_match.py | 9 ++- stitches/fx_recipe.py | 58 ++++++++++------- tests/test_seed.py | 140 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 23 deletions(-) create mode 100644 tests/test_seed.py diff --git a/stitches/fx_match.py b/stitches/fx_match.py index d417b125..38c651e1 100644 --- a/stitches/fx_match.py +++ b/stitches/fx_match.py @@ -78,15 +78,20 @@ def internal_dist(fx_pt, dx_pt, archivedata, tol=0): # Internal fx -def shuffle_function(dt): +def shuffle_function(dt, seed=None): """ Randomly shuffle the deck to assist with the matching process. :param dt: A DataFrame of archive values used in the matching process. + :param seed: Optional seed for the random number generator. When ``None`` + (the default) the shuffle is nondeterministic, preserving the + historical behavior. Pass an integer to obtain a reproducible + shuffle without mutating global random state. + :type seed: int or None :return: A DataFrame with rows in random order. """ nrow = dt.shape[0] - out = dt.sample(nrow, replace=False) + out = dt.sample(nrow, replace=False, random_state=seed) out = out.reset_index(drop=True) return out diff --git a/stitches/fx_recipe.py b/stitches/fx_recipe.py index cecbbe07..9d51801b 100644 --- a/stitches/fx_recipe.py +++ b/stitches/fx_recipe.py @@ -248,7 +248,12 @@ def remove_duplicates(md, archive): def permute_stitching_recipes( - N_matches: int, matched_data, archive, optional=None, testing: bool = False + N_matches: int, + matched_data, + archive, + optional=None, + testing: bool = False, + seed=None, ): """ Sample from `matched_data` to produce permutations of stitching recipes. @@ -267,11 +272,24 @@ def permute_stitching_recipes( to avoid re-making (this is not implemented). :param testing: When True, the behavior can be reliably replicated without setting global seeds. - Defaults to False. + Equivalent to ``seed=1``. Defaults to False. :type testing: bool + :param seed: Optional seed for the per-window archive draw. When ``None`` + (the default) the seed is taken from ``testing`` for backward + compatibility: ``testing=True`` behaves as ``seed=1`` and + ``testing=False`` leaves the draw nondeterministic. Prefer + passing ``seed`` explicitly; it makes results reproducible + without the other connotations of a "testing" flag. + :type seed: int or None + :return: A data frame with the same structure as the raw matched data, with duplicate matches replaced. """ + # Resolve the effective random_state once so the draw below has a single + # source of truth. `testing=True` historically meant `random_state=1`, so + # that mapping is preserved exactly when `seed` is not supplied. + if seed is None and testing: + seed = 1 # Check inputs util.check_columns( matched_data, @@ -441,12 +459,12 @@ def permute_stitching_recipes( # For each target window group, # Randomly select one of the archive matches to use. # This creates one_one_match, a candidate recipe. + # Note: `seed` is applied per group rather than to a single shared + # generator. This reproduces the original `random_state=1` behavior + # exactly; changing it would alter published outputs. one_one_match = [] for name, group in grouped_targets: - if testing: - one_one_match.append(group.sample(1, replace=False, random_state=1)) - else: - one_one_match.append(group.sample(1, replace=False)) + one_one_match.append(group.sample(1, replace=False, random_state=seed)) one_one_match = pd.concat(one_one_match) one_one_match = one_one_match.reset_index(drop=True).copy() @@ -1028,6 +1046,7 @@ def make_recipe( tol: float = 0.1, non_tas_variables: [str] = None, reproducible: bool = False, + seed=None, ): """ Generate a stitching recipe from target and archive data. @@ -1040,13 +1059,17 @@ def make_recipe( :param non_tas_variables: List of variables other than tas to stitch together; defaults to None, which stitches tas only. :param reproducible: If True, ensures reproducible behavior by using the testing=True argument - in permute_stitching_recipes(); defaults to False. + in permute_stitching_recipes(); defaults to False. Equivalent to ``seed=1``. + :param seed: Optional seed forwarded to `permute_stitching_recipes`. When + ``None`` (the default) the seed is derived from ``reproducible`` for + backward compatibility. Prefer passing ``seed`` explicitly. :type N_matches: int :type res: str :type tol: float :type non_tas_variables: list[str] :type reproducible: bool + :type seed: int or None :return: A pandas DataFrame of a formatted recipe. """ @@ -1166,20 +1189,13 @@ def make_recipe( # Match the archive & target data together. match_df = match.match_neighborhood(target_data, archive_data, tol=tol) - if reproducible: - unformatted_recipe = permute_stitching_recipes( - N_matches=N_matches, - matched_data=match_df, - archive=archive_data, - testing=True, - ) - else: - unformatted_recipe = permute_stitching_recipes( - N_matches=N_matches, - matched_data=match_df, - archive=archive_data, - testing=False, - ) + unformatted_recipe = permute_stitching_recipes( + N_matches=N_matches, + matched_data=match_df, + archive=archive_data, + testing=reproducible, + seed=seed, + ) # Format the recipe into the dataframe that can be used by the stitching functions. recipe = generate_gridded_recipe(unformatted_recipe, res=res) diff --git a/tests/test_seed.py b/tests/test_seed.py new file mode 100644 index 00000000..e075c952 --- /dev/null +++ b/tests/test_seed.py @@ -0,0 +1,140 @@ +"""Tests for the ``seed`` arguments on the randomized entry points. + +`shuffle_function` and `permute_stitching_recipes` both draw random samples. +Before the ``seed`` parameter existed, the only way to obtain a reproducible +recipe was ``permute_stitching_recipes(testing=True)``, which hardcoded +``random_state=1``. Overloading a "testing" flag to mean "be deterministic" is +awkward for users who legitimately need reproducible output, so an explicit +``seed`` was added. + +These tests pin the backward-compatibility contract: + +* omitting ``seed`` reproduces the historical nondeterministic behavior; +* ``testing=True`` remains exactly equivalent to ``seed=1``; +* distinct seeds produce distinct draws, i.e. the seed is actually plumbed + through rather than silently ignored. +""" + +import pandas as pd +import pytest + +from stitches.fx_match import match_neighborhood, shuffle_function +from stitches.fx_recipe import permute_stitching_recipes + +from test_fx_recipe import TestRecipe + +TARGET_DATA = TestRecipe.TARGET_DATA +ARCHIVE_DATA = TestRecipe.ARCHIVE_DATA + + +@pytest.fixture(scope="module") +def matched(): + """Return matched data shared by the permutation tests.""" + return match_neighborhood(TARGET_DATA, ARCHIVE_DATA, tol=0.07) + + +def _permute(matched_data, **kwargs): + """Call `permute_stitching_recipes` with the shared test arguments.""" + return permute_stitching_recipes( + N_matches=2, matched_data=matched_data, archive=ARCHIVE_DATA, **kwargs + ) + + +# --------------------------------------------------------------------------- +# shuffle_function +# --------------------------------------------------------------------------- + + +def test_shuffle_function_preserves_shape(): + """Shuffling reorders rows without adding or dropping any.""" + subset = TARGET_DATA.head(10) + out = shuffle_function(subset) + + assert subset.shape == out.shape + assert sorted(out["year"].tolist()) == sorted(subset["year"].tolist()) + + +def test_shuffle_function_seed_is_reproducible(): + """The same seed yields the same permutation.""" + first = shuffle_function(TARGET_DATA, seed=42) + second = shuffle_function(TARGET_DATA, seed=42) + + pd.testing.assert_frame_equal(first, second) + + +def test_shuffle_function_distinct_seeds_differ(): + """Different seeds yield different permutations.""" + first = shuffle_function(TARGET_DATA, seed=42) + second = shuffle_function(TARGET_DATA, seed=43) + + assert not first.equals(second) + + +def test_shuffle_function_without_seed_is_random(): + """Omitting the seed preserves the historical nondeterministic behavior. + + Repeated draws are compared rather than a single pair to keep the odds of a + coincidental match negligible for a 28-row frame. + """ + draws = [shuffle_function(TARGET_DATA) for _ in range(5)] + + assert any(not draws[0].equals(other) for other in draws[1:]) + + +# --------------------------------------------------------------------------- +# permute_stitching_recipes +# --------------------------------------------------------------------------- + + +def test_permute_seed_matches_legacy_testing_flag(matched): + """``seed=1`` must reproduce ``testing=True`` exactly. + + This is the backward-compatibility guarantee: published results generated + with ``testing=True`` remain reproducible via the new parameter, so the seed + refactor cannot have altered any existing output. + """ + legacy = _permute(matched, testing=True) + seeded = _permute(matched, seed=1) + + pd.testing.assert_frame_equal(legacy, seeded) + + +def test_permute_testing_flag_is_still_reproducible(matched): + """``testing=True`` remains deterministic across calls.""" + first = _permute(matched, testing=True) + second = _permute(matched, testing=True) + + pd.testing.assert_frame_equal(first, second) + + +def test_permute_seed_is_reproducible(matched): + """A given seed yields the same recipe across calls.""" + first = _permute(matched, seed=1234) + second = _permute(matched, seed=1234) + + pd.testing.assert_frame_equal(first, second) + + +def test_permute_distinct_seeds_differ(matched): + """Different seeds select different archive points, proving the seed is used.""" + first = _permute(matched, seed=1) + second = _permute(matched, seed=99) + + assert not first.equals(second) + + +def test_permute_without_seed_is_random(matched): + """Omitting both ``seed`` and ``testing`` preserves nondeterminism.""" + draws = [_permute(matched) for _ in range(5)] + + assert any(not draws[0].equals(other) for other in draws[1:]) + + +def test_permute_seed_overrides_testing_flag(matched): + """An explicit ``seed`` takes precedence over ``testing=True``.""" + with_seed = _permute(matched, testing=True, seed=99) + seed_only = _permute(matched, seed=99) + legacy = _permute(matched, testing=True) + + pd.testing.assert_frame_equal(with_seed, seed_only) + assert not with_seed.equals(legacy) From a9ee00d6210b2d41d99a444067c7dfbb4966277f Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:07:38 -0400 Subject: [PATCH 05/14] test(regression): add golden-output invariance harness with fx_match coverage Establishes the safety net required before any refactor, dependency bump, or performance work can be trusted not to change scientific output. Harness (tests/regression/conftest.py): - GoldenComparer with assert_frame (Parquet artifacts), assert_digest (SHA-256 for artifacts too large to vendor), and assert_values (JSON for filenames and summary stats). - canonicalize() sorts columns by name and rows by full content, making the comparison insensitive to row/column ORDER but fully sensitive to CONTENT. This is deliberate: groupby/concat-heavy code legitimately reorders across pandas versions, and a suite that cries wolf on every such change gets ignored. Where order is part of the contract it is asserted explicitly. - Named tolerance presets rather than one global epsilon: exact (0) for indices, years and labels; 1e-12 for match distances; 1e-10 for temperature values; 1e-6/1e-9 for gridded fields. Integral and categorical data must be exact; float reductions get a tight but nonzero tolerance because NumPy and pandas may reassociate between versions without any change in meaning. - Artifacts are Parquet, not CSV, so float formatting cannot mask a real diff or manufacture a fake one. - --update-golden regenerates artifacts and prints what it rewrote along with a reminder to document the change. Coverage (tests/regression/test_invariance_match.py, 14 tests): a tolerance sweep over match_neighborhood in both dedup modes, self-matching, row counts, internal_dist, drop_hist_false_duplicates (whose min-idvalue tie-break is order-sensitive), and seeded shuffle_function. Validated by mutation testing rather than assumed to work: perturbing dist_l2 by one part in 1e9 produced 3 failures with a precise column-and-index diff, and the suite returned to green once reverted. All fixtures are the committed example CSVs, so the suite is fully offline and runs in ~2.5s. --- tests/regression/__init__.py | 27 ++ tests/regression/conftest.py | 268 ++++++++++++++++++ .../match/drop_hist_false_duplicates.parquet | Bin 0 -> 16439 bytes .../golden/match/internal_dist.parquet | Bin 0 -> 8722 bytes .../golden/match/internal_dist_tol0.1.parquet | Bin 0 -> 8722 bytes .../golden/match/neighborhood_row_counts.json | 7 + .../golden/match/neighborhood_self.parquet | Bin 0 -> 14124 bytes .../match/neighborhood_tol0.1_dedup.parquet | Bin 0 -> 14666 bytes .../match/neighborhood_tol0.1_nodedup.parquet | Bin 0 -> 15101 bytes .../match/neighborhood_tol0.5_dedup.parquet | Bin 0 -> 14666 bytes .../match/neighborhood_tol0_dedup.parquet | Bin 0 -> 14311 bytes .../match/neighborhood_tol0_nodedup.parquet | Bin 0 -> 14311 bytes .../golden/match/shuffle_seed20240101.parquet | Bin 0 -> 6037 bytes tests/regression/test_invariance_match.py | 162 +++++++++++ 14 files changed, 464 insertions(+) create mode 100644 tests/regression/__init__.py create mode 100644 tests/regression/conftest.py create mode 100644 tests/regression/golden/match/drop_hist_false_duplicates.parquet create mode 100644 tests/regression/golden/match/internal_dist.parquet create mode 100644 tests/regression/golden/match/internal_dist_tol0.1.parquet create mode 100644 tests/regression/golden/match/neighborhood_row_counts.json create mode 100644 tests/regression/golden/match/neighborhood_self.parquet create mode 100644 tests/regression/golden/match/neighborhood_tol0.1_dedup.parquet create mode 100644 tests/regression/golden/match/neighborhood_tol0.1_nodedup.parquet create mode 100644 tests/regression/golden/match/neighborhood_tol0.5_dedup.parquet create mode 100644 tests/regression/golden/match/neighborhood_tol0_dedup.parquet create mode 100644 tests/regression/golden/match/neighborhood_tol0_nodedup.parquet create mode 100644 tests/regression/golden/match/shuffle_seed20240101.parquet create mode 100644 tests/regression/test_invariance_match.py diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py new file mode 100644 index 00000000..3e84fdcf --- /dev/null +++ b/tests/regression/__init__.py @@ -0,0 +1,27 @@ +"""Golden-output regression tests for the stitches package. + +The purpose of this package is to guarantee **output invariance**: refactors, +dependency upgrades, and performance work must not change the numbers that +stitches produces, except where the current output is provably wrong. + +How it works +------------ + +Each test calls a function and compares the result against a *golden* artifact +recorded from a known-good baseline. Artifacts live in ``golden/`` as Parquet +files (stable, typed, compact -- unlike CSV, which invites float-formatting +drift that can both mask real diffs and manufacture fake ones). + +Regenerating goldens +-------------------- + +Golden files are regenerated only deliberately:: + + pytest tests/regression --update-golden + +Policy: any PR that changes a file under ``tests/regression/golden/`` **must** +include a ``CHANGELOG.md`` entry under ``### Changed -- outputs`` explaining +which defect the new output corrects, and must be reviewed by a domain +maintainer rather than only a code reviewer. An unexplained golden update +defeats the entire purpose of this suite. +""" diff --git a/tests/regression/conftest.py b/tests/regression/conftest.py new file mode 100644 index 00000000..bbefacef --- /dev/null +++ b/tests/regression/conftest.py @@ -0,0 +1,268 @@ +"""Comparison helpers and fixtures for the golden-output regression suite. + +The central helper is :func:`assert_matches_golden`, which compares a DataFrame +against a recorded Parquet artifact. + +Canonicalization +---------------- + +Before comparison both frames are passed through :func:`canonicalize`, which +sorts columns by name and rows by their full contents. This deliberately makes +the comparison insensitive to row and column *ordering* while remaining fully +sensitive to row and column *content*. + +The rationale: ``groupby``/``concat``-heavy code legitimately changes iteration +order across pandas versions and across refactors that are otherwise pure. If +ordering were significant, the suite would emit false failures on every such +change and would quickly be ignored. Where ordering is genuinely part of the +contract -- for example, `make_recipe` sorts by ``stitching_id`` and +``target_start_yr`` before returning -- it is asserted explicitly by a dedicated +test rather than implicitly by the golden comparison. + +Tolerances +---------- + +Integral and categorical data must match exactly. Floating point results are +compared with a tight but nonzero tolerance, because NumPy and pandas are free +to reassociate reductions between versions; demanding bit-equality there would +produce failures that carry no scientific meaning. See ``TOLERANCES``. +""" + +import hashlib +import json +from pathlib import Path + +import pandas as pd +import pytest +from pandas.testing import assert_frame_equal + +#: Directory holding recorded golden artifacts. +GOLDEN_DIR = Path(__file__).parent / "golden" + +#: Named tolerance presets, keyed by the kind of quantity being compared. +#: +#: ``exact`` - indices, years, labels, filenames, counts. Any change is a bug. +#: ``distance`` - the L2/dx/fx match distances; a few ULP of drift is acceptable. +#: ``value`` - temperature values derived from means over the archive. +#: ``field`` - gridded NetCDF field values, which accumulate more error. +TOLERANCES = { + "exact": {"rtol": 0.0, "atol": 0.0}, + "distance": {"rtol": 1e-12, "atol": 0.0}, + "value": {"rtol": 1e-10, "atol": 0.0}, + "field": {"rtol": 1e-6, "atol": 1e-9}, +} + + +def canonicalize(df): + """Return ``df`` in a deterministic row and column order. + + Columns are sorted by name and rows are sorted by their full contents using + a stable sort, so the result depends only on the *set* of rows and columns + present, not on the order the producing code happened to emit them in. + + :param df: The DataFrame to canonicalize. + :type df: pandas.DataFrame + :return: A new DataFrame with sorted columns, sorted rows, and a reset index. + :rtype: pandas.DataFrame + """ + out = df.reindex(sorted(df.columns), axis=1) + if len(out) > 0: + out = out.sort_values(list(out.columns), kind="mergesort") + return out.reset_index(drop=True) + + +def frame_digest(df): + """Return a short, stable SHA-256 digest of a canonicalized DataFrame. + + Used for artifacts that are too large to vendor, where only a checksum is + stored. Note that this is exact by construction and therefore cannot express + a floating-point tolerance; reserve it for frames whose values are integral + or categorical, or where any change at all is worth investigating. + + :param df: The DataFrame to digest. + :type df: pandas.DataFrame + :return: The first 32 hex characters of the digest. + :rtype: str + """ + canonical = canonicalize(df) + hashed = pd.util.hash_pandas_object(canonical, index=False) + return hashlib.sha256(hashed.values.tobytes()).hexdigest()[:32] + + +class GoldenComparer: + """Compares frames against golden artifacts, or rewrites them on request. + + Instantiated by the :func:`golden` fixture. When ``update`` is True the + comparison methods write the incoming data to disk and pass, which is how + ``--update-golden`` regenerates the suite. + """ + + def __init__(self, update=False, golden_dir=GOLDEN_DIR): + self.update = update + self.golden_dir = Path(golden_dir) + #: Relative paths written during this session, for reporting. + self.written = [] + + # -- frames ---------------------------------------------------------- + + def assert_frame(self, actual, name, tolerance="distance"): + """Assert ``actual`` matches the golden Parquet artifact ``name``. + + :param actual: The frame produced by the code under test. + :type actual: pandas.DataFrame + :param name: Artifact path relative to the golden directory, without + the ``.parquet`` suffix, e.g. ``"match/tol0"``. + :type name: str + :param tolerance: Key into :data:`TOLERANCES`. + :type tolerance: str + """ + if tolerance not in TOLERANCES: + raise ValueError( + f"unknown tolerance {tolerance!r}; expected one of {sorted(TOLERANCES)}" + ) + + path = self.golden_dir / f"{name}.parquet" + + if self.update: + path.parent.mkdir(parents=True, exist_ok=True) + canonicalize(actual).to_parquet(path, index=False) + self.written.append(str(path.relative_to(self.golden_dir))) + return + + if not path.is_file(): + pytest.fail( + f"missing golden artifact {path}.\n" + f"Generate it with: pytest tests/regression --update-golden" + ) + + expected = pd.read_parquet(path) + + actual_c = canonicalize(actual) + expected_c = canonicalize(expected) + + # Report a shape or column mismatch directly; assert_frame_equal's + # message for these cases is much harder to read. + if list(actual_c.columns) != list(expected_c.columns): + raise AssertionError( + f"{name}: column mismatch\n" + f" unexpected: {sorted(set(actual_c.columns) - set(expected_c.columns))}\n" + f" missing: {sorted(set(expected_c.columns) - set(actual_c.columns))}" + ) + if len(actual_c) != len(expected_c): + raise AssertionError( + f"{name}: row count changed: expected {len(expected_c)}, got {len(actual_c)}" + ) + + assert_frame_equal( + actual_c, + expected_c, + # Integer width and categorical-vs-object differences vary across + # platforms and pandas versions without any change in meaning. + check_dtype=False, + check_categorical=False, + obj=f"golden[{name}]", + **TOLERANCES[tolerance], + ) + + # -- digests --------------------------------------------------------- + + def assert_digest(self, actual, name): + """Assert the digest of ``actual`` matches the recorded JSON digest. + + For artifacts too large to vendor as Parquet. + + :param actual: The frame produced by the code under test. + :type actual: pandas.DataFrame + :param name: Artifact path relative to the golden directory, without + the ``.json`` suffix. + :type name: str + """ + path = self.golden_dir / f"{name}.json" + digest = frame_digest(actual) + record = {"digest": digest, "rows": int(len(actual)), "columns": sorted(actual.columns)} + + if self.update: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n") + self.written.append(str(path.relative_to(self.golden_dir))) + return + + if not path.is_file(): + pytest.fail( + f"missing golden digest {path}.\n" + f"Generate it with: pytest tests/regression --update-golden" + ) + + expected = json.loads(path.read_text()) + + assert record["columns"] == expected["columns"], f"{name}: columns changed" + assert record["rows"] == expected["rows"], ( + f"{name}: row count changed: expected {expected['rows']}, got {record['rows']}" + ) + assert digest == expected["digest"], ( + f"{name}: content digest changed.\n" + f" expected {expected['digest']}\n" + f" actual {digest}\n" + f"If this change is intentional and corrects a defect, regenerate with " + f"--update-golden and document it in CHANGELOG.md." + ) + + # -- scalars --------------------------------------------------------- + + def assert_values(self, actual, name): + """Assert a JSON-serializable mapping matches the recorded values. + + Useful for pinning things that are not frames, such as generated + filenames or summary statistics. + + :param actual: A JSON-serializable mapping. + :type actual: dict + :param name: Artifact path relative to the golden directory, without + the ``.json`` suffix. + :type name: str + """ + path = self.golden_dir / f"{name}.json" + + if self.update: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(actual, indent=2, sort_keys=True) + "\n") + self.written.append(str(path.relative_to(self.golden_dir))) + return + + if not path.is_file(): + pytest.fail( + f"missing golden values {path}.\n" + f"Generate it with: pytest tests/regression --update-golden" + ) + + expected = json.loads(path.read_text()) + assert actual == expected, f"{name}: recorded values changed" + + +@pytest.fixture(scope="session") +def golden(request): + """Return a :class:`GoldenComparer` honoring the ``--update-golden`` flag.""" + update = request.config.getoption("update_golden") + comparer = GoldenComparer(update=update) + + if update: + comparer.golden_dir.mkdir(parents=True, exist_ok=True) + + yield comparer + + if update and comparer.written: + print( + f"\n--update-golden rewrote {len(comparer.written)} artifact(s) under " + f"{comparer.golden_dir}:\n " + "\n ".join(sorted(comparer.written)) + + "\n\nRemember to document intentional output changes in CHANGELOG.md." + ) + + +@pytest.fixture(autouse=True) +def _mark_regression(request): + """Apply the ``regression`` marker to everything in this package. + + Keeps ``pytest -m regression`` accurate without requiring each module to + repeat ``pytestmark``. + """ + request.node.add_marker(pytest.mark.regression) diff --git a/tests/regression/golden/match/drop_hist_false_duplicates.parquet b/tests/regression/golden/match/drop_hist_false_duplicates.parquet new file mode 100644 index 0000000000000000000000000000000000000000..1d6b882285bcb4ea793a0c02d01584f9a00a87bc GIT binary patch literal 16439 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 HZ;<~3itmJ( literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/internal_dist_tol0.1.parquet b/tests/regression/golden/match/internal_dist_tol0.1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..7ea5f4464360f9b0d129fb7ab9db618713b98943 GIT binary patch literal 8722 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 HZ;<~3itmJ( literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/neighborhood_row_counts.json b/tests/regression/golden/match/neighborhood_row_counts.json new file mode 100644 index 00000000..41a94777 --- /dev/null +++ b/tests/regression/golden/match/neighborhood_row_counts.json @@ -0,0 +1,7 @@ +{ + "0.0": 56, + "0.05": 66, + "0.1": 66, + "0.2": 66, + "0.5": 66 +} diff --git a/tests/regression/golden/match/neighborhood_self.parquet b/tests/regression/golden/match/neighborhood_self.parquet new file mode 100644 index 0000000000000000000000000000000000000000..942a47e2b5d556b7a322ed5316481d1415b1019c GIT binary patch literal 14124 zcmeHOZERcB8NSX(>y)On1seyl5kk3?eh|k=+5}aj*G}v>Bq2##e@(3Nx1C%&#&+U3 z6%(5fLTqBvh7g(<+NKR{Y#NA38$X!DB=|9YpoyVvV*Kb2HffuNrfJ$LhS;9>+%wsl>+zk7F= z@b_N3aH-ecC7kYY30-K|fxW}_-~?R2tw2BUSzrLT0~iGE1P%dr0U~fWa2U7; zIMQoBbo8{qo@1vS0)#kq46pr;U5;J-E;}FLzb@YI6b@VT9yoxR{c;E13Y!NoON`tE$tcT2DxgwQXK|Y4Cod@2cY#o) zQnI4cMg1=Jwx9wZ`^aGWE}PI}6FPd}W1TP%xmGu@3)l^ioAm&DfExgExn5u&a3etO zN5&-=ya^yTbO7Xvw*cgheE^Jq>NdreLcjXgYY~S6x+))W*&&x^F>`Nw7Tf1I$USVgs}tm^@rs4PI??T-R~zQ~ z95Yt{3M~LpAOWlZRp1feao}sfv%q(N7lEGwuK{lYZv(#rE&_iB{sH_4=IUbRB4;FL zyB+4@`DnPMx%i074!JaQnS0waSA&Boz$Q!At9&l5@zY{#s&{+gBzuEIbTQ@t9+OUY8*1) zK*W0a{K9G~EgPq5Y%0}wxoYOV9xvzP2JS?DFQ-;d$0050v^$lrR5gW^7BSbyWRKG7 z0iSGIpWCapjk@2c^_f*az{e~#=hV64d)3({y*Z0UwRx|V);Fl6n9|PizW%T)rsKfpu5k0{5f^cu0By1$JP_K zE|pu8&v1^v?-Va}i|=)UrMbeQYmB)U*O;wY?p0Z8u97Y)+rpSWX>)$vCVsG6d}Vk4 z33J(HBJF&(qtB=ir*7fWjQ(Rb=T~jwKkechcB0={j+v=nv^7xY<}ySCH`TkH;$LnM zf87I9nQJ04mASWVQ#Hn$L63KP#qaepJsQfSw)N2Vfcq`i1{?qOe(~jf(B`;$VQ-^~ zG4XBiHNG_(QyvzK=@*@k+r*z86rVUq`WUMi6IJIAtaqg$kr*`ju|s_7CTLF&f_853Qf2;UmpB1mG)r<4uDYhZaz4aOKmD@CK$4+xAZt-DjYt6V78;=Krk9_wI z@v+;%(VWo8N6fvK^%3sv{0y+ja!=hUzCXCFEN4Ewm`X~Ld;XC4;hoz^a(rhp+5v zd96^a4Bj{J`G<}T3@Xj@Y%JZ>%bFJki}6Al(+BxAFSRjPMxJ755+ZCdl@qfLCbqqtf&nt2)qn%HeO zxiHFBj%^rdV!3S%WlswYm4PO9+tpAlRISo>!_c*1t0(2g6GLUK4>z&jj*&ImdO~i& zT%XXyejCxlxi=(ZYyJ&Wc3c80uU3WI@ocTq)v~drzS`)Ht7=TdmaL5vnz-+Pry0|* zC41w9CidI+G@ghpIrG6Lu2-rw-4)~2YB`@+!>0<|(k|eNHkd4@84n!$IIo_8YQ{)bh(mWEmE&%>|Xc1!-KR=6~-_)g0Zo|M+DbiX=&-f@O{cRQrDwL zhUht&U63Sc5uoP*Ng9>BS;;R+S;?1WeO_(nq8jX17*AvsBYN9xoe=zS&M9$Q*!GhW+Pm~gcB{>}kZ3dTD zv*BTPCFq-3PIxwk6X7wXZ={k>6#UiLncxYG<&uT@)p#T(M?6cT!93B5m15zIm5667 zmk6H&4=Epw%q+(PvL_arSV{YWM6(tRkF7)_^O@ANTvKTk5`l?aYBK6p=!^{~BHppc z$kJFcpwNjGHrAt&MbErKqZ*4W_)cM^%#~@0Y7wI%F$S&7^)g}l zL+)sJDVGXNk#9h+jYIdAI7*a2K>lB{!L>%H`vSU zm&O>oQdvF7Ii;u`7hB4jD){U1us@$bo}t`eC2PVn^KdLY-!eBr-?|d7R~YXjvK(zE zCmH5h$+y$Cj0NOP>^0BV7WP{zLFY_tT8SwunVRyo*%u43O2Xs6!kAI{wvOeMcx2uk z@qC(btjLs@Y3nR<&)kTEBF;%|pDfNj)0|IjFN??#g_I0G zyWH8V%ZPhJWJKP=j>fZh2XeGg#>TVO922eQUi)*-z&kzdm6HXz8pCJCAKSgXVRXCe=k>MlN2MO~yHnG+zZ666 zScKJSC+QxJI;{>F-HDMP-Oo|yY+w#*u;L1)Mq{o%TAN)ym(QTKL|rOL)9O7KWu~9%&1qJ9CaJa@ zmF7@iW+ySThVoNdp!Pz|CrOi2REFlM9Q9|>mR6`eK}+YTEcH!M?Rt*d-YnXa#7QdC zYnootIK5c?x^VX7&=57Ju!uT&JxmRHDu7qZ73pv&m&{7Fp@pF#Hhhk~SJk%H&xo=j zS)tE{Avzv?k7GEOlk_(1&kVJ7eU?V4omp4qhYL5YkJ7kdJ@ZQSPF$JG`W{y2`=kdq zCmQ{exdz)>q9I8nidVanO1`s48~nk$skWyOG&FUq^(1#fogb9WEFd(<#;xo}^l;Ae zdR>hTbozor>kamk)HaWgW`AJ&WB87v=V>-FIZErRP4Y0K8~lOkXOPEGBa)=jQNEr& zhv6CL?N`75{YDhXilFGkd0U;Vp|A};tXD)Z+ixjahzD<2JYd61Qx zpV5Ek(N$^8$dmy`1V literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/neighborhood_tol0.1_dedup.parquet b/tests/regression/golden/match/neighborhood_tol0.1_dedup.parquet new file mode 100644 index 0000000000000000000000000000000000000000..3bbe69faccb03432d585b5c79ed9786e79427a0c GIT binary patch literal 14666 zcmeHOdu&_hb-$D;n{wntmVFtjb`w=mZX8<q}Y6|t-(uPNt7-r zMx-9Jks-^BVDsFxa04_LyrLVLVhFOd*w8d6`bP$I85R^n;TCDp28GdfD_Wq1`#>7B zLA&4i?t_>2UfNV7_pcE8@$!3}^PR_c&UY^CX>m5lyZIY#{#$Jm`~=T+bKIBPIIi90 zzVTT5E;n~`kB57AkH^iO>hN)HG(12j&;@h@`vEWD13m;C1nvWRfsX)vdpx}lociQv zOWl9NJ+P;X>(}rgo&-U|LwFJnX?Pe7LpD?2`vQSNkvj+d${H z2cP@R$*%pUuMWuS3~T^@4O|9316%`M1YQBY3cL>dH*g*J0q_&x7r;BfpI`!yr~ONA zZZBG-=Lhz9P7Y{N4r)(mMB&Q~3eksrv@!M}h1h$ULXLe$(?|P0?o#hJe3zb=z8Uen z_=WFeu6*VH-oDuN#-SIlRc6PR{`IZzU-Z9m;-@cv<%bt(1=P^bADX~}{HJ@Q9eq`~ zQVr@qLf9E*n09uIJJe27LO%8gGXC8o8h_Wmm+)Eq!K=j`AR6EYCSYHp?K|iO?MkH_ zgjb=fqsp4+rE*#uHOFmRr>3oy~F#jTfP4#yuXv!hRmt9(ux?thcHzrA<++`o(8Kf$*#+jy95$f&S~ zm;aE(9yH){(>LRO*CE~3mt0Qg=uvi#qEO)ka1QtcFb|Z08t@G8_rUYOKLTF_z687m z{0HzR@NM92;D3Q%0>7hB#?Da(S`@zy;2ia9=jUkibEFUXJlGif&^faAZuK19&3Ehi z+~p$;RkIZ*b?!jFkG@(`=0}+_V-R;5NCOhE1XO{ifM?5`)c^sSo))*qEVAd+%12*~|CqL8FLLy)%*B9lanH6XhVAT;GQNU+k4r*?m3}5@C4UEp#+h{nc?fiaN3cfHe_LF z>)jl~Pwn{e&wurdqlEfUBSU*{Q-(-)@0!xxHs2H|tFe&!Af6Of9|9<}J`5nd_8&Q= znvrFHYp&CWbm7$GZ|~izTYZr7S<3I`y?UtX=+!fQ&?#7{Dc+pvtMksGPkzxtm zl-8|;V4L5Y{L}5?I$T^^2X3-E;6ikPZ3lJ&y8z1Uln8bMcL8*Zy8*&e`*xcZl=ttmGEtVwOM5@w*>v^X<;ARckWM{Ap9zsKma zi@sMMvq?_Wbn3d`o!V@h+&YU!vGrajsqfNAF`>PeTs;SS-s1)CAiuk3h}+rKW40^b zh6&!McX{7&3-7iGp3N2OJp)u+@@*Wxy6`yp@Xj6ql{>6Y{M;Hpi`%F?zuE46&MjQq zCH&D1ruNd8Vz9jzZhjl4of=caU*wW%8;Wws80>F5ykDaLr3XnQJy1Vege4jporsY6vZ6ao*?={^eeXW3QXYK=$6- z8%XyEJ#?wuzVGt>$R)gUKzQ{4xY?^A;%4vNEH~ZxRBqq|=dfY45!~KiydxQ-|4G zo_R?4Nk4enBOS?T@4Y=w-TCzl(HQIFAq04UJ)uY0-BhZ%pZcf^=_qMLBhV*NeXWo5Nks`NeD`KS z>1EA}eWh$s#`He@QkdW9!@{|OtmMx-kEu-0S8HXqa86myR*wzRs?ut)Tyq*I%GGQ> zTg`$|X;o348tAn%R&S8anb*fH8z__<*&E%hF;}lcw_;zPVcEd4v?%AQb_Goojc#vrw_J+n@ldhMM!%M=g8}3Ezj{TA^v@X&SJw+p3!}%1+L08n_cIl~5BY)q!nh zsg<`*;k#kz&akZq=f=#Tv91qW*l)#^4BGX;+=O|3f`$DSPLR&MAt*cZZF+FxG1iRScfMOiJl-@XoJHb<_{l_7nRjaEHRi0 zO|3~|iKUz9={K`A+;_Ju12 zsW@8AJP|*Jv4vc5YB`(AD5=owV7x%IGUZHiV<{CHT9A_Gz(XvgQ{#)-m=ek)B1>{K zPBhok$)TlmYHBV&rmSnUic&1HkRM40R60XJDK$Kl3eOJZVk(_Xabqo=nh8y*G^&}@ z>_R3s8c5S#VwfM>h)hIDR!80nWSf&y@xnw@EM$s`BE@FsoMlO@W=11JxkA&umZg%h zkJWrKxGpCm0Xa3Sklskwbvc4cJ~j$jXBTKpDkUn?81y>UOpl4xTqsdZ7dMm^^r=Xt zDMd<-SD?dYdR-|E0Ox1~$Oa#)&*@b-UH2DVP+NjLIKjhl@ zYC0LzWE-ueLks16F)^1(Ev#o!<7KQ{&8s#kW|N5ubl2*9CqKAz`z_74VEc^f$ED!~ z_{%~rXtrynB85f+Q;SV?Y4PFB+CI33Gg@iE?o}x?t4Phy(jC)bE)1QNT0BSLsQ}J? zAQe_>OeRO2n$L>;%x9BBb$xHrKl8=WS~fXakPts^d*;I##4=^UICqYFjTExQ#G>Zw z_H$pF3TQE7v$1I?lN?obbnEz8pXa#0?bhXN8TOvbj5R$sOWD*^z=-{hd%7cMM&qlU zRR^7AV#Q)xtvxw+UV^780~TjV*rpA{`SZRK2)aBhov z1v=4luzhd#IZpA}665S=_Wk5(9HYp*z=w_hqrWI&3n9E zj&3^F@r-P5Tru$;%*7IjfkE68)STsT@1Yo7!d=QKpF7!qdvik0 zof~V8b%@)B?^@^nInPa4QPR!p1Vfi{GO=*yYJ^~(?kuPq0=SnH@~K%RS3{jMRz;12 z{_r^VIJ*Y^=tC_LQwllUcVx9LTbFU4$Q2`D_1=|Ha;2%V6dGjp*yQ55)ye4KM!dx8 zyZTuwCTh8mvL+GDX#B#&qPRNs#CQZ%WQT*BUFw~ic!>~r>QJWP&+z<_DF!1u2UHspFx>AJVWgmwI3(S(F%R%=xao;e?`$h zz2^5*b0*Fz-!U3ji(vLEN@HV2o?~| z!-kDbcSx~3eZlV!8S5bwKDSwKI&b1Kt!Lt(+{dS=w`XVA)}S4H*iZJ06ls!2z4NmX zane8M_t*6G&GbQ}>iQ_kZ0*@dgFdD&SoFd8*Yz<>KkNEvsQHNA7^AmN`=N=W?4<*H z38B7!pgE#Q?>t1~Y$M0(`BOYY>m&2}dU1hf+SX4|r+GcA&*@dinUO|$#uv5r=@BNx zaNU21hA1upCV9q)NHnJLkBXDl^)znw5Aw=k?f$u56DKA#esM7}9oGDFk|{ApD^2!f zTZw6R>e9RzUeo5!i}QZCHW3y#*rGIjtEyye=IYwratU@kqs@Ka#5wvR;xjZ3m(7VR?!ft&z1gVh&sKk6?<-CIfI5*8!~0Co zPt$u%^`kvGUvD|P^5m*qJ(??*k7nt`YOnU# z7?Ju60;pM6WLb(1Zu)^CEz&M2j9_VtHO+>?D>MV$vJ40Yx7&(!>x`sZ*9=?SV%UPM z==MAJ-A6vY_bACR-M>QU$H)8e&euKX+;cDNNqO2M+QqH?;+B0(%!xv?AZ+gwgj!q2 zjsCw`|Ec70+~}{pyF<9yzi_qi8JGQLW0UZ{iVk6*@1x%iHq;0M^=`qAPJ)A@0Z*W< zY2cpjf9$BUHKE$SeH?O%r_0HL*!@=uAQ=d&e?)qH% z@BjRXFaG4IHrKOnk6eB^{iV*|f92gb|LE0!`;NQyuYNxEy>Dgy31qN7+xx^{+Q!5) zY<+In9RB@SA>n+ZL%0Au4$J~ez&h}8;91}^zze|3z}J932DX8}1g--=1bz(sJMdq? z|1~=74oB_R?ZP3njso{JI?i@+jeGeM=Gn4|pDZ_zHs~VJ*oVwx?q%i~xY^ImQ+q_% zYI?dU{+#T1;AL0hEuZ7d&tINic=fxk#aDel`O%Y2*Ia+_{PJ7D2VVVFbn(4Eb-nY# zH~)6?y+&q}4zY6#rXi1Q9;yY;LMmVIXg|CVvs3KSt>G!5vzDfKUF_juq8uLMlGu*C zp7|mFYXn)h*foZwC+e#ix4d`w206mL5qMcL;&GI6>aW; zzc_#TcVxkhwui%uC!vR<7f(VT$0MDIVfzpZ+d%}@7;q8z zC@=@)fDPbj;CF%NfiD7I1-=ga3GipYo51&ge*pd&_z&Q}5w-#g+j_K`fqM|P9Xwo5 z?i9A#pvwW#*oVT_+*`%4y-RG?jJw}O<|@SUR<_-Teiwc1$+C|y%LbtPBrpTSfkmJI zJOz9b_%!eZ;LE@(z&C-bz;}VSfWHH706ztO4*V}HYiE{iM9T>rgJnCpMLj!NRvUCV zAX?F~=H4n=c2e|@i3gb5b_&8FTiYu?yFMP@>KCpwI?jB9|9(+@!r~Xih|Zg_ukk6` z4kv7pFPM9o#K&Lap1$wMfMBb!JDS=)w!LNBSJ&WjZ}E-+^#=ulNwg~@#$> z!tZUh&q_2tWDw2NYZ46->?YdB#ECJnfmzKh2#vOmdm7()_h(OU^&dM}?-2U2-tRT5 z9iQH(J}m^)rj!e4?llPrQx$8t-wcsHsD^M9l#cky2*jM$$@&wfqKb-I>~`L z$$|RF(Q#rxbzfE&nKyoIP}5wki_E=M^z#pkA#*X*EIPHUTHmJCKpv~Ssun-`8pyb7 znYKTwXRJuELPuBb7ztsa{zvDzcAHRd6Kd+=x%=Q;N2auU z7jQQ~PEBb>@XWncbX}8a zKddb1yi}b7!H1OvOj)Wos^_*Xzi{>muDX0;K9z}y#|81g{WY!wvE{^kdNp+^xsF{5 z9d~IRsciDn# zTq4%U+j`yaF7}yKKctPBG$(2rbxrXGKHH?X#-dzpt=CHHgPaso+Ihv+(%SM1Q4(6k z11)_*T~mvGUBwzqaDKbqxm_n+u}h9yy9R0LqSLupBjBqEkFyWdwMe)V!Riw~JLhL` z8DFAnJqx_dESBT%9?_rl|MVcNi%7X2laRo6yk`e+8N&nd({b2)fD8Dc;(|@tHoW5z1X4i|0bgfSMM6>jE zBMfFfD3ig=y?ZxUdFZJ;pS3wJ+obOwmR>r{bSXOw@2-pH0L8V?oLHrqHC#AQKgQq#uk&fox-Fa%xuk8@!>F@V!&M(-ctKHJG z-Np3hDV-F|{$DBmnRho;Ej43YcS~P!YuV3JJU^Jp8?&EgF>Qr3|HBHsWS8FRkzVt_ zTIK?ttYz-qyS2);U%e*JoRWUfcRRUHTL-Ju_l!?^_Z0N0e(KY>T4lcNho#F8fAGww zFE8}2eDx9OOJ|{sImOZ9n0t3#9OsMEs7xe>^E(6B@m+PfST>2wx_en)cU2w zlCqM?=Gz}?`_0F@+S=9TB{r5a^s?rY?b%o+h3V~DQJ7qB$BVP+l#;w;J*G0fRM^O| z7iX2FSm9JJy_H?b1_g>ar%S4q0(9bXkPz3|fX{a)9ZPdWGsNn}toF6pJJaq#G zcDL(hjIxz;>jv%wOT}A3OLd^iEcx-)Dt(s?-5Iu}r=5M zd;J6h``h<4Eg4&K)&>n+Z&hik8DoXQayq_(OASe@Qq zO`p?qld>#N0rWX9%e}IHP9B!!IeBo7_4)l}<7?D;t~mba6yBjWK8y+*56beIjQ8*) zB76ysrLM)(oqaoj7YA{GN*Ji?fi!+gl+2n|_#%X2Z0pEObXvVG5>GQ-R z{=P`hbYCK%(uro)S7#zq-U*dPAsU&Uj|PU_GbBp@^8@R?u|d++lD7igW>b-1dTdZm zM>C2N4@}Qm>k?Xt4*U8N>58)E;@P4cE6K2DEfw;)QxU&Hb|YJt^zbOjz%X>3o~N;R zHk6N#z^)^e>=;@}cteGm%(}7%dn)nlgc1*r=3&E1c3qvxp z5!EK~aL7Fqo}Nzz&XI3Gul4*a{3Eq7x-t{?aNUMCX1w#cWF|Bljm)n_BcnOIw~|!X zBohmV^03`r=Ue%~os%~^w}-Wls(zgH&%M9m|9kxUZYzK0D#&IpbDyQ(rVZtlDVj{8^f3DPOg1 zIi16L&qhZo#?4|ZGT|=feoL9|NX&4)JXS4i77Zwy$zfmSSH0hGzWH%=@36>+s!iiV zYR*jrhOsXXZ|<MXidVgiyY0~x=UT={b@PgjcW)vPLJstxPEhwOi@Jw$ zbQZOg)qZZZ{?*-EN>P~B}&9Rb8zn&H`qtA_d+Lw!ViHCiQ(ePTOJ;ZXJl|RP4 zqfbP`6BX-QbrViw@1;_$e>RyQw)9WGCMd8dnd&?AIWTjTHAe zRx8Ov3un5!&(JuS;4?JD+vhH_Lo}qphy7%~WSZ+QuCcC$lyj4t-QC{e`%ns>UEbHl zo4iEtGj(v>$0wt;r>B@`uns=#C;LT))aj$v{A@%X@1E`M-q7B!WDg=$vPUMx-o4-} zv&ZD7!5)l%$sT_CS+YmjnUCm=FnVhw4^13qmk#U_LcM>WIkHT59&$0ZvBzusr+l8? z_swbV%kwnT^!@~O>fdMQbGqs{KUA*I=mKw_8)8cMOa4PNWO)(L=`%t^1_PY`pgeAT zpT_n6L0;+S_2=4#JT}Jp1!do45BJY;X2bx!sasDbN`TkY**UpqmCv7(=eptAL|9&D zFU`=mYD(5-uFh-AMXcj_K7UX?w|PQc&vO<0k@WYM?rGHA#)vLVo-Xspd9BU;ag@y} z`6II@>NM^Aua6Cy+D-4DUNFdG^e39Bm#1__q!D>i(}&0FfwD=&V z`t@Z*b@ybYdmYvMF#VJ|e|!j}m_B^Hjs8jEm&v2vWnmgp6%zEE3_ny;4GD3PzKHk~ zjl*RVGRr$~KDOR$RP|@0Kd}2soj>55$cW)S6ZB{3UQ_+>r{HSK#g?B~Nfk~ea=DW+ gx>#-F7pHCNl`8(ZBK|GM?fePXj|;*i{^RTa0|To@ZU6uP literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/neighborhood_tol0.5_dedup.parquet b/tests/regression/golden/match/neighborhood_tol0.5_dedup.parquet new file mode 100644 index 0000000000000000000000000000000000000000..3bbe69faccb03432d585b5c79ed9786e79427a0c GIT binary patch literal 14666 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 BKw$s? literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/neighborhood_tol0_nodedup.parquet b/tests/regression/golden/match/neighborhood_tol0_nodedup.parquet new file mode 100644 index 0000000000000000000000000000000000000000..2077ba634a1f84ee1eabe302aece3ebfd00155a6 GIT binary patch literal 14311 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 BKw$s? literal 0 HcmV?d00001 diff --git a/tests/regression/golden/match/shuffle_seed20240101.parquet b/tests/regression/golden/match/shuffle_seed20240101.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5c41a3f3577c4db750f5478c50295c0b4aad3752 GIT binary patch literal 6037 zcmcgwZ)_W989ygZ;&e?(v)R}zfHAGqz*A&`I%APtFqU>_zlCV>V*`7k~J0!;`(1=8U6ym#l= zK08X;*r~t!^E~hS{Q14l`@C*e`E=OrbU#1nzUx?W&%3z^j@x%|+>pb$|LD+&lY4#4 z#oZlqIk`)tUe1XR7jPUH2PS|A01w~=-UUno?*aV4`+xxOe&7^v8VCX(0L}mp0UsQ5 zoqG5Z$F8YMZVo|QoWgC~J?tKy^tvpK^|t@0hda|ldh#SjPAUVq&pU=j{ZH|?7tVa^ zg)iLFzysLV!zW59g?mE0I`0(b{Nj90~~JScwBsF*casH zkqmipnqJJTElaR`5o}w8j0(Snz9OEbfjLXS3$%6L+iNtmv_zFO3JkcLLF$H8i|88RQ1qxM3(08iz8Bk=gDpZ@a=POO2THV>BV0NWy zP2SN+Ium&$^hh{B^UPq{l(W!90=GEtaXy`XEPKKD9pe4?#57&!HxoS1uK{#j<@q^2 zy2;0Rev^-FGFdd*q2D9nm6rbMCf?~A7qi0jDW2csF%M5N5~FtpK1pMv=ToXu&nGf- z>oY66VltzP+OqtZ92M8E?QN!1Q>H9bL^Z8QiOh}E*3M=&9IB;ai(BGMBP?bkhHSPb zi)viYUrU{XZd;0_XpAakGqrqtp`Ocz6`Gq;({m{qwzX&(Hp_+ZLP%PVn!0>VoQa24 zw$|jO7%z)jMlU3lQZcFQnRZn%u~05v&V`r_HLMg9@$KAtx=gk)z9uFSTe2e-1%67> zdWzZ1tBN8f(xtXoJM@J_9DKxYtk0$+g~WAm!OKE6a#&0iQCpi?G4j>(hz0XQIpQG^ z35$uw!cvTQj+Di$VJ{EP^4YK~!an8MGbU|C5wnXm&S zxsD66pk}s;1}E?r-jgzI>#VJW3Np*-&#%zv)DV@p>=%&d!t#5FGNhP-wuvua_ftg+(F&i_rI1eqkhUcwV}LS zTuIqX+2o){Jc^Ptd&B=@?;CwI9x5imiT;B-pV~qqAe0gv)e(7$=WF- zDl^(Tx~%hs@k%rKJ!?E2XSnLtcY2M@Pco?yAic)umOQ8v`s8Dm>6F}_-kqlKqI{W2 z3v{P5&Af2VA-{vY!NjRm%w)%*;Xh%0oBnO_Qz!{zKa+tHjSrFkd3;@oF%qIIUiM03 z*b&P|RS9oJ%f6YfXnps~m#D=V+}3}2e&uoadpL(1%QtQ$$rhnI-?sCF0wF#S7sYou zWz9GF>YmS1$NcT}S*3{y?Dqor$C9w2%pA)UM!_ERIX=3_FO$M_!p86odhp%r!Im*g-Yct`(fobe>nM#pH#j(_`i3*f84A16ZR_pApc*6 CdZ`Zp literal 0 HcmV?d00001 diff --git a/tests/regression/test_invariance_match.py b/tests/regression/test_invariance_match.py new file mode 100644 index 00000000..8063c96f --- /dev/null +++ b/tests/regression/test_invariance_match.py @@ -0,0 +1,162 @@ +"""Golden-output regression tests for the matching functions. + +Covers :mod:`stitches.fx_match`. All fixtures are the small committed CSVs under +``stitches/data/example``, so these tests run entirely offline and are cheap +enough to gate every pull request. +""" + +import pandas as pd +import pytest + +from stitches.fx_match import ( + drop_hist_false_duplicates, + internal_dist, + match_neighborhood, + shuffle_function, +) + + +@pytest.fixture(scope="module") +def target(read_example): + """Return the example target data.""" + return read_example("test-target_dat") + + +@pytest.fixture(scope="module") +def archive(read_example): + """Return the example archive data.""" + return read_example("test-archive_dat") + + +# --------------------------------------------------------------------------- +# match_neighborhood +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "tol,dedup,name", + [ + (0.0, True, "match/neighborhood_tol0_dedup"), + (0.0, False, "match/neighborhood_tol0_nodedup"), + (0.1, True, "match/neighborhood_tol0.1_dedup"), + (0.1, False, "match/neighborhood_tol0.1_nodedup"), + (0.5, True, "match/neighborhood_tol0.5_dedup"), + ], +) +def test_match_neighborhood_invariant(golden, target, archive, tol, dedup, name): + """`match_neighborhood` output is unchanged across the tolerance sweep. + + The tolerance sweep matters because ``tol`` controls how many archive points + fall inside each target neighborhood, exercising both the single-match and + many-match branches of the distance filtering. + """ + out = match_neighborhood(target, archive, tol=tol, drop_hist_duplicates=dedup) + golden.assert_frame(out, name, tolerance="distance") + + +def test_match_neighborhood_self_match_invariant(golden, target): + """Matching the target against itself is unchanged. + + A self-match is the degenerate case where every distance should be zero, so + it isolates the distance computation from the neighborhood selection. + """ + out = match_neighborhood(target, target, tol=0) + golden.assert_frame(out, "match/neighborhood_self", tolerance="distance") + + +def test_match_neighborhood_self_match_distances_are_zero(target): + """Every distance in a self-match is exactly zero. + + This is an absolute property rather than a recorded value, so it is asserted + directly instead of against a golden file. + """ + out = match_neighborhood(target, target, tol=0) + + for column in ("dist_dx", "dist_fx", "dist_l2"): + assert (out[column] == 0).all(), f"{column} is not identically zero" + + +def test_match_neighborhood_row_count_is_stable(golden, target, archive): + """Row counts per tolerance are unchanged. + + Recorded separately from the frames so that a change in *how many* matches + are produced is reported as its own clearly-named failure. + """ + counts = { + str(tol): int(len(match_neighborhood(target, archive, tol=tol))) + for tol in (0.0, 0.05, 0.1, 0.2, 0.5) + } + golden.assert_values(counts, "match/neighborhood_row_counts") + + +# --------------------------------------------------------------------------- +# internal_dist +# --------------------------------------------------------------------------- + + +def test_internal_dist_invariant(golden, target): + """`internal_dist` output for a fixed probe point is unchanged.""" + out = internal_dist(target.fx[0], target.dx[0], target) + golden.assert_frame(out, "match/internal_dist", tolerance="distance") + + +def test_internal_dist_with_tolerance_invariant(golden, target): + """`internal_dist` with a nonzero tolerance is unchanged.""" + out = internal_dist(target.fx[0], target.dx[0], target, tol=0.1) + golden.assert_frame(out, "match/internal_dist_tol0.1", tolerance="distance") + + +# --------------------------------------------------------------------------- +# drop_hist_false_duplicates +# --------------------------------------------------------------------------- + + +def test_drop_hist_false_duplicates_invariant(golden, read_example): + """`drop_hist_false_duplicates` output is unchanged. + + This function resolves ties by keeping the row with the minimum ``idvalue``, + which is order-sensitive, so pinning it guards against an incidental change + in groupby iteration order silently changing which duplicate survives. + """ + match_data = read_example("test-match_w_dup") + out = drop_hist_false_duplicates(match_data) + golden.assert_frame(out, "match/drop_hist_false_duplicates", tolerance="distance") + + +def test_drop_hist_false_duplicates_leaves_one_historical_experiment(read_example): + """Only one historical target experiment survives deduplication. + + An invariant of the function's purpose: identical historical data pasted into + every future experiment must collapse to a single representative. + """ + match_data = read_example("test-match_w_dup") + cleaned = drop_hist_false_duplicates(match_data) + + hist = cleaned[cleaned["target_start_yr"] <= 2020]["target_experiment"].unique() + assert len(hist) == 1 + + +# --------------------------------------------------------------------------- +# shuffle_function +# --------------------------------------------------------------------------- + + +def test_shuffle_function_seeded_invariant(golden, target): + """A seeded shuffle produces an unchanged permutation. + + Pins the permutation so that a future change to how the seed is threaded + through (for example, moving to a single shared generator) is caught rather + than silently altering user-visible reproducible output. + """ + out = shuffle_function(target, seed=20240101) + golden.assert_frame(out, "match/shuffle_seed20240101", tolerance="value") + + +def test_shuffle_function_preserves_contents(target): + """Shuffling is a pure permutation: no rows are added, dropped, or altered.""" + out = shuffle_function(target, seed=7) + + pd.testing.assert_frame_equal( + out.sort_values(list(out.columns)).reset_index(drop=True), + target.sort_values(list(target.columns)).reset_index(drop=True), + ) From 716154a80490147dd889ff67c7108630f2298891 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:11:02 -0400 Subject: [PATCH 06/14] fix: repair NumPy 2 and pandas 3 incompatibilities in fx_processing Adding golden-output regression coverage for fx_processing immediately surfaced two hard failures that make the package unusable on a current scientific Python stack (tested against pandas 3.0.5 / numpy 2.5.2 / scikit-learn 1.9.0). 1. get_chunk_info raised TypeError: only 0-dimensional arrays can be converted to Python scalars. LinearRegression is fitted against a column vector, so coef_ has shape (1, 1) and coef_[0] is a one-element ARRAY, not a scalar. float() on a one-element array was deprecated in NumPy 1.25 and raises in NumPy 2. Since get_chunk_info computes the dx (rate of change) values that the entire matching algorithm keys on, this broke archive generation and recipe creation outright. Fixed with float(model.coef_.ravel()[0]), which selects the single coefficient explicitly. Verified value-identical to the legacy float(coef_[0]) semantics across 200 randomized fits, so no output changes. 2. calculate_rolling_mean raised ValueError: Cannot specify both 'axis' and 'index'/'columns'. drop(columns='value', axis=1) passes both selectors; pandas 3.0 rejects that combination. The axis=1 was redundant because columns= already implies the column axis, so it was removed with no behavioral change. Both are forward-compatibility defects rather than logic errors: the intended results were always well defined, the code simply relied on deprecated coercions. Also adds tests/regression/test_invariance_processing.py (20 tests) covering calculate_rolling_mean across window sizes, chunk_ts including staggered base_chunk offsets, get_chunk_info fx/dx values, and subset_archive, using a deterministic seeded synthetic series so window arithmetic is exercised at known lengths. Includes property assertions that do not depend on recorded values, such as min_periods=1 leaving no NaN at the series edges and each chunk's representative year lying within its own bounds. Confirmed the previously committed fx_match golden artifacts are byte-identical after these fixes (git diff on tests/regression/golden is empty), so the changes are provably side-effect free. Adds CHANGELOG.md, including the output-change policy requiring any golden artifact update to name the defect it corrects. --- CHANGELOG.md | 75 ++++++ stitches/fx_processing.py | 12 +- .../golden/processing/chunk_info_n5.parquet | Bin 0 -> 7378 bytes .../golden/processing/chunk_info_n9.parquet | Bin 0 -> 6619 bytes .../golden/processing/chunk_ts_n20.parquet | Bin 0 -> 8452 bytes .../golden/processing/chunk_ts_n5.parquet | Bin 0 -> 8779 bytes .../golden/processing/chunk_ts_n9.parquet | Bin 0 -> 8547 bytes .../processing/chunk_ts_n9_base0.parquet | Bin 0 -> 8547 bytes .../processing/chunk_ts_n9_base1.parquet | Bin 0 -> 8535 bytes .../processing/chunk_ts_n9_base4.parquet | Bin 0 -> 8483 bytes .../processing/rolling_mean_w11.parquet | Bin 0 -> 8031 bytes .../golden/processing/rolling_mean_w3.parquet | Bin 0 -> 8031 bytes .../golden/processing/rolling_mean_w9.parquet | Bin 0 -> 8031 bytes .../golden/processing/subset_archive.parquet | Bin 0 -> 5868 bytes .../regression/test_invariance_processing.py | 229 ++++++++++++++++++ 15 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/regression/golden/processing/chunk_info_n5.parquet create mode 100644 tests/regression/golden/processing/chunk_info_n9.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n20.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n5.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n9.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n9_base0.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n9_base1.parquet create mode 100644 tests/regression/golden/processing/chunk_ts_n9_base4.parquet create mode 100644 tests/regression/golden/processing/rolling_mean_w11.parquet create mode 100644 tests/regression/golden/processing/rolling_mean_w3.parquet create mode 100644 tests/regression/golden/processing/rolling_mean_w9.parquet create mode 100644 tests/regression/golden/processing/subset_archive.parquet create mode 100644 tests/regression/test_invariance_processing.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..46d37f2c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to `stitches` are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Output-change policy + +`stitches` produces scientific data, so a change in output is a change in +published results. Any modification that alters output must be recorded under a +`### Changed — outputs` heading, must state which defect it corrects, and must be +accompanied by regenerated golden artifacts under `tests/regression/golden/`. +See [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regression-testing.md). + +--- + +## [Unreleased] + +### Fixed + +- **`get_chunk_info` crashed on NumPy 2.** The per-chunk rate of change was + extracted with `float(model.coef_[0])`. Because `LinearRegression` is fitted + against a column vector, `coef_` has shape `(1, 1)` and `coef_[0]` is a + one-element array, not a scalar. Converting a one-element array via `float()` + was deprecated in NumPy 1.25 and raises `TypeError` in NumPy 2, making the + function — and therefore all archive generation and matching — unusable on + current NumPy. Now uses `float(model.coef_.ravel()[0])`, verified to produce + values identical to the legacy behavior across 200 randomized fits. +- **`calculate_rolling_mean` crashed on pandas 3.** The call + `drop(columns="value", axis=1)` passes both `columns` and `axis`, which pandas + 3.0 rejects with `ValueError: Cannot specify both 'axis' and 'index'/'columns'`. + The `axis=1` was redundant because `columns=` already selects the column axis; + it has been removed with no change in behavior. + +### Added + +- **Golden-output regression suite** (`tests/regression/`) asserting that + refactors and dependency upgrades do not change scientific output. Artifacts + are stored as Parquet with per-quantity tolerances (exact for indices, years + and labels; `1e-12` for match distances; `1e-10` for temperature values). + Regenerate deliberately with `pytest tests/regression --update-golden`. + Validated by mutation testing: a one-part-in-10⁹ perturbation of `dist_l2` is + detected. +- **Explicit `seed` parameters** on `shuffle_function`, + `permute_stitching_recipes`, and `make_recipe`, so reproducible output no + longer requires the `testing=True` flag. Fully backward compatible: + `seed=None` reproduces the previous behavior exactly, and `testing=True` + remains equivalent to `seed=1`. +- **Pytest capability markers** (`network`, `slow`, `package_data`, + `regression`, `benchmark`) with `--network`, `--slow`, and `--package-data` + opt-in flags and matching `STITCHES_TEST_*` environment variables. + +### Changed + +- **Tests no longer download package data implicitly.** `tests/conftest.py` + previously installed the full Zenodo archive in a session-scoped `autouse` + fixture, so every `pytest` invocation downloaded hundreds of megabytes. Package + data is now an opt-in `package_data` fixture; the offline tier runs in seconds. +- `tests/test_pangeo.py` and `tests/test_stitch.py` converted from + `unittest.TestCase` to pytest functions, with the monolithic test methods split + into single-behavior tests. + +### Removed + +- **The `RUN = "ci"` test gate.** `test_pangeo.py` and `test_stitch.py` guarded + their real assertions behind a hardcoded class attribute whose false branch ran + `self.assertEqual(0, 0)`. These tests reported as *passing* in CI while + exercising no code, leaving the Pangeo and gridded-stitching paths effectively + untested. They are now marked and skip visibly instead. + +### Documentation + +- Added `plans/` with a development plan, a git clone-performance analysis, and + the benchmark/regression design. diff --git a/stitches/fx_processing.py b/stitches/fx_processing.py index b8c88ff6..f8c80c2d 100644 --- a/stitches/fx_processing.py +++ b/stitches/fx_processing.py @@ -51,8 +51,11 @@ def calculate_rolling_mean(data, size): # dplyr group_by %>% mutate() call. # rename so that value is the smoothed data: + # Note: `axis=1` must not be passed alongside `columns=`; pandas 3.0 raises + # "Cannot specify both 'axis' and 'index'/'columns'" for that combination. + # `columns=` already implies the column axis, so the intent is unchanged. rslt = ( - rslt.drop(columns="value", axis=1) + rslt.drop(columns="value") .rename(columns={"rollingAvg": "value"}) .reset_index(drop=True) ) @@ -160,7 +163,12 @@ def get_chunk_info(df): # stored in a data frame. model = LinearRegression() model.fit(x_input, y_input) - dx = float(model.coef_[0]) + # `y_input` is a column vector, so `coef_` has shape (1, 1) and + # `coef_[0]` is a 1-element array rather than a scalar. Converting a + # 1-element array with `float()` was deprecated in NumPy 1.25 and raises + # TypeError in NumPy 2. `ravel()[0]` selects the single coefficient + # explicitly and yields the identical value. + dx = float(model.coef_.ravel()[0]) # Format the the chunk data into a pandas data frame. row = pd.DataFrame( diff --git a/tests/regression/golden/processing/chunk_info_n5.parquet b/tests/regression/golden/processing/chunk_info_n5.parquet new file mode 100644 index 0000000000000000000000000000000000000000..029344d3ac61b94a3f80afcaef46f11b915e3904 GIT binary patch literal 7378 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 dOb>qer3XFWlh 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/chunk_ts_n5.parquet b/tests/regression/golden/processing/chunk_ts_n5.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5210810636083e847b48bd841ca26f5122b32ce5 GIT binary patch literal 8779 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/chunk_ts_n9_base0.parquet b/tests/regression/golden/processing/chunk_ts_n9_base0.parquet new file mode 100644 index 0000000000000000000000000000000000000000..1f60a6fc705fec613db6b57cba5bb804975df7c5 GIT binary patch literal 8547 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/chunk_ts_n9_base1.parquet b/tests/regression/golden/processing/chunk_ts_n9_base1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..1b5565f6e313bcde2dfdbbcc554859707418e761 GIT binary patch literal 8535 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/chunk_ts_n9_base4.parquet b/tests/regression/golden/processing/chunk_ts_n9_base4.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9ba4d3471bd9de26703e5c3336406a9e189449b7 GIT binary patch literal 8483 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/rolling_mean_w11.parquet b/tests/regression/golden/processing/rolling_mean_w11.parquet new file mode 100644 index 0000000000000000000000000000000000000000..fb2fb8fd2ea199cb9d251e1dd4fc627e6f7c5f3d GIT binary patch literal 8031 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/rolling_mean_w3.parquet b/tests/regression/golden/processing/rolling_mean_w3.parquet new file mode 100644 index 0000000000000000000000000000000000000000..40fe0c7ea1c9a51606c374d8046d6e0c0c7623d6 GIT binary patch literal 8031 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/rolling_mean_w9.parquet b/tests/regression/golden/processing/rolling_mean_w9.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9b96d2a17d53a3d8d7a7c715cef8ee9af6731903 GIT binary patch literal 8031 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 S{M)hpAE7iQh|%z0>;DBPm@Sn6 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/processing/subset_archive.parquet b/tests/regression/golden/processing/subset_archive.parquet new file mode 100644 index 0000000000000000000000000000000000000000..bd61059c58ccb878d9d04542d10631645fde1120 GIT binary patch literal 5868 zcmcgw-HYSa6_-7;vtI89lg-L#+93?45!+^xu|4)~W(gT2?^?F!BV+H5Wh-o_k|o>n zNU~$gU&>o{VlTi%ugTw=*(~b{KtR1 z`S2qpm-zAB?!#N({kHfQ?(NO}Z`8iWwtuoo5@dw@$|}e$k_+U*G8J+-RLH@I5LbPu zPd!Bt#QCd1c!vXXbN4d@*l?9xb!@n@efKOu)L`6!u?2&L;n6pbc2M((2YOs9Uwn#O z-X??C#|x_=P~C~E@pilx2i(hKWa7s)u-V~J;~B<0w_@2G2qTNHkd%WM{@MZ{cDwCF zGUbD}U~wq>zYo?_241fJzVXGcA8mfCz`fe~WqY&!t3UkWXU)$yzk2qK4%t9E1Hi_;R%;=&3$~E%IrR5 z>zgpPVWeT)fZ@$7+Cj}D9?I-wRlsHm#vTj~#>+6g{-GVzJmMk$CM!SxmOe=lP&w6xqKo7ef($bjTQ?1_({J4a zLYSehDR#^;>U_^o$D&xxk=Rj1ml`BAKUBmH5eY9XFFl}%Wiq^!A{G{xkfmfW5{|qX zjNA{>13L8O`oz*Id}!b*is2&sd+F;7OEiIs6DbMWO6(|LMIQ$up9bj9gY>(>Wy;kQ zP@pFb0|I?A^aUGw98(PbULf**fWALZe>S&#?YKVS0gmh~xvh4fU75k)?*<}24A38( zq5n0Hz`aew3;fa)@Hpl*cg+8@K)-{!v$qRCNPMWL4vkeg*z^wqk)H?X&qDMMLdbMi zKbYwaUKivk7o3caYtWU`cf$1V&OuHm?EoBbtxjz?u4oM0iHzuv&eLz5M^-y6!fRuA z%0?%DA?G{5P;W%u4$u!S(Er6)?tbCLdu9sH@fm+YE`J|gq~E#-{!ZEh@^`X2(cj2F zFGj+2DDufAD0FI}EgCX(u)m+X7gY^e85~qwT2D8+(HkqT-d$aZTFV2>Q)Z}mc`s^+ zx&qsy@*oNVHC55%gK3(jeBO%Z^No~FINx-wB@ zwKlJ^34KPPYMAQ@ueve$WMx+C^iwzC%qVP0O{HpjHFnJoBA%#k+?9PaPVt_c&YDl= zEZU5Y$ITo*QoMeR$usI6BaF^bssY7OyTh z=gH|q$G2CPL!Hqxj?^gwh&qgDnjKZ@!L1R#^?UH4N2?u0G+Q0CjB2XX5j#WFU)n>l z(`ogQF-Jb@w`tl&Thfb~!j(q3W~UNq@C8K0kvsz8gYJD45m$;3Fe6te67tVVLywwW)*DtxgabGJ}#don-h)*!#iFz?DsDOu%8LlN=>{v2zUl4Z53 zu9@J2l>^^AZm6POYRXm(fWCNNDY^1&?8d5JCSFGUID2%v&ivvtQ!9{*GMjSeRnP}V zm2x4mYuS0K+)4b#p}_Z#v&WPYMNP8s{A_x%IfFP>%K2D%T^m~8gU}ZE!C^TuskQ1l zd}GF>{{J8KE%T|ioqO;1Zjfy_dj_*rPww@Kf)UfN{#~w|q*uBaxK9AdrtiGO!$ytbtQHlv=-0!cWegOS` zDDat>w4OtK0(#Z}_T-^{4sjoZUJUd6kvzmbDIt{eBaz?e;lAF?#kZSGZ|8O%`W>TA zY!|euvD=msNsO^GO!l{%`)bWP={R&0IeTFW=N`kv(J_{*!!gC~qq8iJ)_eH;+Ch8` zy*PX*Ceak1XWX6V*4AEx)vYu>1mW;R3-q!cq>xR4e?z>^kL>*n3x^{p8O!fm^3M5+ zcf$+ev6Fhj_TPMQ?PzVSzjqy8z%CXrmBS) n`` is rejected.""" + with pytest.raises(TypeError): + chunk_ts(series.copy(), 5, base_chunk=6) + + +def test_chunk_ts_rejects_multiple_variables(series): + """A frame holding more than one variable is rejected.""" + mixed = series.copy() + mixed.loc[mixed.index[:10], "variable"] = "pr" + + with pytest.raises(TypeError): + chunk_ts(mixed, 9) + + +# --------------------------------------------------------------------------- +# get_chunk_info +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("n", [5, 9]) +def test_get_chunk_info_invariant(golden, series, n): + """`get_chunk_info` fx and dx values are unchanged. + + ``dx`` comes from a scikit-learn ``LinearRegression`` fit per chunk, so this + test also pins the package against a change in that dependency's numerics. + """ + chunked = chunk_ts(series.copy(), n) + out = get_chunk_info(chunked) + golden.assert_frame(out, f"processing/chunk_info_n{n}", tolerance="value") + + +def test_get_chunk_info_one_row_per_chunk(series): + """`get_chunk_info` summarizes each chunk to exactly one row.""" + chunked = chunk_ts(series.copy(), 9) + out = get_chunk_info(chunked) + + assert len(out) == chunked["chunk"].nunique() + + +def test_get_chunk_info_years_are_integers(series): + """Year columns are returned as integers, not floats or categoricals.""" + chunked = chunk_ts(series.copy(), 9) + out = get_chunk_info(chunked) + + for column in ("start_yr", "end_yr", "year"): + assert pd.api.types.is_integer_dtype(out[column]), f"{column} is not integral" + + +def test_get_chunk_info_year_bounds_are_consistent(series): + """Each chunk's representative year lies within its own start/end bounds.""" + chunked = chunk_ts(series.copy(), 9) + out = get_chunk_info(chunked) + + assert (out["start_yr"] <= out["year"]).all() + assert (out["year"] <= out["end_yr"]).all() + + +# --------------------------------------------------------------------------- +# subset_archive +# --------------------------------------------------------------------------- + + +def test_subset_archive_invariant(golden, series): + """`subset_archive` selection is unchanged.""" + staggered = pd.concat( + [chunk_ts(series.copy(), 9, base_chunk=offset) for offset in range(0, 3)] + ).reset_index(drop=True) + info = get_chunk_info( + chunk_ts(series.copy(), 9) + ) + end_years = sorted(info["end_yr"].unique())[:5] + + archive = info.copy() + out = subset_archive(archive, end_years) + golden.assert_frame(out, "processing/subset_archive", tolerance="value") + + assert set(out["end_yr"]).issubset(set(end_years)) + assert len(staggered) > 0 # staggered construction must not silently produce nothing From 2270d0defb3ec7ace35e9cf04ce14cf3b408bead Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:14:16 -0400 Subject: [PATCH 07/14] test(regression): add fx_recipe, fx_util, and fx_data invariance coverage Extends the golden-output suite to the remaining pure-Python core, bringing the offline suite to 98 passing tests and 34 golden artifacts (384K total). fx_recipe (21 tests) -- the most algorithmically involved module, and the one where an accidental change is hardest to notice: - get_num_perms targets and guide frames, which determine how many collapse-free realizations each target supports and therefore the order the whole construction proceeds in. - remove_duplicates, pinning which target year wins a contested archive point and what the loser is re-matched to. - permute_stitching_recipes at N=1 and N=2 and at two tolerances; the wider tolerance gives more candidates per window and exercises the sampling and duplicate-rejection loop far harder. - handle_transition_periods and handle_final_period, which rewrite the windows straddling the historical/future boundary. Beyond recorded values, this module asserts structural properties that hold for any valid draw: recipes cover every target window, draw only from the archive, never reuse an archive point within a realization, and exhibit no envelope collapse across realizations. It also asserts target coverage stays contiguous after transition handling, since a gap there would silently yield a stitched series with missing years. fx_util + fx_data (15 tests): - selstr, nrow, combine_df, and anti_join, including the cross-join row count and the shared-column rejection. - global_mean, which applies the cosine-of-latitude area weighting. Verified analytically as well as against a golden: a spatially uniform field must average back to its own constant (catching a normalization bug), and a cos(lat) field must yield a weighted mean strictly greater than the unweighted mean (catching weighting being skipped entirely). This matters because a weighting error would bias every emulated series while still producing plausible numbers. - get_lat_name under both the 'lat' and 'latitude' spellings. While writing these, remove_duplicates was found to require singular matches and to raise TypeError otherwise; that precondition is now covered by an explicit test rather than left implicit. Confirmed no previously committed golden artifact changed. --- .../golden/data/global_mean.parquet | Bin 0 -> 1725 bytes .../golden/recipe/handle_final_period.parquet | Bin 0 -> 6668 bytes .../recipe/handle_transition_periods.parquet | Bin 0 -> 6668 bytes .../golden/recipe/num_perms_guide.parquet | Bin 0 -> 7402 bytes .../golden/recipe/num_perms_targets.parquet | Bin 0 -> 4140 bytes .../golden/recipe/permute_N1_seed1.parquet | Bin 0 -> 15951 bytes .../golden/recipe/permute_N2_seed1.parquet | Bin 0 -> 15951 bytes .../recipe/permute_N2_tol0.2_seed1.parquet | Bin 0 -> 16984 bytes .../golden/recipe/remove_duplicates.parquet | Bin 0 -> 15320 bytes .../regression/golden/util/anti_join.parquet | Bin 0 -> 1618 bytes .../regression/golden/util/combine_df.parquet | Bin 0 -> 2134 bytes tests/regression/test_invariance_recipe.py | 272 ++++++++++++++++++ tests/regression/test_invariance_util.py | 187 ++++++++++++ 13 files changed, 459 insertions(+) create mode 100644 tests/regression/golden/data/global_mean.parquet create mode 100644 tests/regression/golden/recipe/handle_final_period.parquet create mode 100644 tests/regression/golden/recipe/handle_transition_periods.parquet create mode 100644 tests/regression/golden/recipe/num_perms_guide.parquet create mode 100644 tests/regression/golden/recipe/num_perms_targets.parquet create mode 100644 tests/regression/golden/recipe/permute_N1_seed1.parquet create mode 100644 tests/regression/golden/recipe/permute_N2_seed1.parquet create mode 100644 tests/regression/golden/recipe/permute_N2_tol0.2_seed1.parquet create mode 100644 tests/regression/golden/recipe/remove_duplicates.parquet create mode 100644 tests/regression/golden/util/anti_join.parquet create mode 100644 tests/regression/golden/util/combine_df.parquet create mode 100644 tests/regression/test_invariance_recipe.py create mode 100644 tests/regression/test_invariance_util.py diff --git a/tests/regression/golden/data/global_mean.parquet b/tests/regression/golden/data/global_mean.parquet new file mode 100644 index 0000000000000000000000000000000000000000..f7483e7e1f55dca55e694d059607984b4a285554 GIT binary patch literal 1725 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 literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/handle_transition_periods.parquet b/tests/regression/golden/recipe/handle_transition_periods.parquet new file mode 100644 index 0000000000000000000000000000000000000000..74785ef96d1a3491443de5d343ee87041069689f GIT binary patch literal 6668 zcmc&(ZEPEN89(RM$&xOKdX_`;A@WvhgS7E$S6lsZc5BC(7bsTcT%^2o&nke zssVL@J`40B&?`VkKyL%R1N1J?FM;j>{T}F#K=*LLTm<2YGzRymV{(R?@Nf91&;Ov% zd^8bx@;kqK_Q*kjk3Kq-h9C6M{Tak;1Vn$`zvm~K=F|s%KVp+oP!09Q`PXejMVI2HF977N`PL2kHTR z4(KJIuK>Lc^j)AI0{sN&J)qwJ{Q>AtKz{}L0HRNk=+D4v38H^akNV>yqi;yuPOyhu z7=0`E!J>bLUNX*Z+C7eE$2M&aZ5-HcyxAPnC+UlZ3VIt;PK(=}hF{B_JANC5iuZwp^@=1B*WEd<(lHN`UU32$P?fC`Kz~m@1?%|M}*=_M$_^JDHaY=f_gZDChX%DcS=Mb80q;h5PJWKI&! zQ|T4RJtAD{3%hlpAZ_M%fG(g_R3MVZl8)Mn(z}&^xXd9NvFD;{DYl=-XhEwrF*|?HKaL z2>wv|5~cSXuJ;|@qj~SWIn-)x%l4hxiunHY5TbE@!*xL*U!QdSdE9$%!TZ)3I6P~w zbistPazFC$3`BF)4Pbu%toMscfNAYlh-u}Xi0S&qIak!{bls;Rs_v;etCV4Eetq_~ zM^wr}cbBh8Em>)LqN|^NW^L7@Eq94bFmcK9wnt%Q0k(ULj-|Cg54^l53Q~D@v|Iz< zReN=!KvdLAA$(71$@N~JUE}rzUPZnvsBD>4SwK@-lJxxAsvWx4A5OwQ-eIz!rHDLy zl0CEy{g!@$6+?SI9y`eF7;Qpd{PCb0t!K0WgF8$%oJuYjeQ2}=gF8$XJY+5mPFyR# zS$ewILqpvPZV{W%AL{H)9}gqZPc!z|s905Ph+GT);so8{Hnav0-w;^%n;R}YlE^jK zMi1Lmy~j2hwGJBA78*^lrXV*EzUaH?_uzZ_X$%O1APak*r`&T)HhVoE)xQ1Q@19-9 z>r!QdVVE00cs{L>+&XXc_PR>d~GIyBTuT}DX zUo#co+~)#aKbH?{ykJx0h^~CWUj}; zIdaMkF&$^bk}OGFGP^e-4wn6VG6Atq91fmgIUlYU1CayWuU0wl?+CfU7(_%?&h3}u z#Ba3(=LiMhb#!G?T?`1OCV^AK{USAM{}<%5u? zN_I~uq(<7y=EJ_sz7{9QMU~%>26Lrdl`e~K?k$P!V4%qMe zU)7fnYFvSw9_~f5wHIK1lma=QslVCTS%>-zWHf(IrJnlO!3^5#>-Wr)>@@GSi#yDM zhwHEYGUgxjTp#3^ST1BGz6X6_Lxr9I{NSbpvNp^D;6WcwN+J)rD`+#LBjgi3UXBE{ z{$G-KB~#}DA=2Bo_IFxa@lZFVklAy5&T_8D2c$NKI^(Is^gh$d+}w;XOc91$Oy9Q! z&j&zPD>xe^9FvI-j=>tPFTn$o##`(%-pTkR_t?&Qdwm@ROcf>C5GC`IESYWmv5NhqdB`rsb3T^fr92^!DL~{u%y|_qFd2+(iy4 zcKjVMkbgu;>`;H7(atA%%g~oN;e3wu1^m#Ta!u4Fqdj5s`(}IK6NnkGCy*PQhYYiY z_N=Gyh8ts9)C~Cn*Fe09QrqtU?fAg-8@lU|V&uE42wBKr0%EYWU8qvaC$s+RxNk_m zOy&x3D;QUBf@ieU!JS$@Q0~<_+jy=es2BKp{Q~(gUDZE4SG7;l64${fBW*=wh@gRj@+_n?w#q$%TjF>4X0|+W*HN#fYUJJWd|{!QF)E?gL}*Jsl$!*FS$Wth|rd{MARd zU7@(003ti^g<>Lw6)NQdCL&U*<%n!#GDMbFHQhzt(>eFo`!=mqAGAF6a>pmN7*FW8 zvPnYaFH~aXGVB8g>nMl;G`U} z8?AT=w{>b-(9BUyP!rMkH2^#lS4@&TA3y|~GW&}IscOm)D(gV(&BB$!jMZ~0V8&Q1 zqSfgO%vX`IDEYR)T#~J=)oo2(^V^B^7elw)()#f`#Pye_pR7Fdd0MvQ#O}VwPo*!} zyr<^RZO76V+tW2qxIaoS|L}C}YI%8==DGSc{@(u(6^CB@gQ9;sF|R&x!GRN>67v^X z=FfZcPw7`)JVe%LCell7m;K^5i{xEPFS~o;#G1;keY1~6zkOTQRj0duz3S=Gu8oi0 zw0rIEP7qJ+-C3)eeKP&*_R@X2z&FGTgzM^0*opM6>n^^MIsRGto43|m8?QK-E`2?n zetU2t-G6%PeP2aRrax|G@c!!0)24fuy|U}*iL~Xa7dGsbol3uZnYZJ(^;B9KZec}` zBX}QCxTV_Zg}V`N5`uGqt{Q^F(lK^0H0q<70s*R?v?@iv3kNFa$UMs(nSa2h9s*ea z@_UdyaAYp!j?8H|0&julLy&_Y4}!G9k(mQ+7067G2{a2F9XCrZszH7Ko0FR69KmFTiIC99Akz5r>{w0rE;gtwgVB(DfWg*Zbhm z2*~vy`#@d-x@L29eGTp(0?o%DZ-Q(D(Ewd@p{)f`fSdrjT+o&QU0b049q@Y<$aau^ zko!Scpo`)uFc+mulcno=5nWR4wCHMhNsqw^_Xg;Y2cHEA;>r?bRd_JuVL=tc@M`qU zdZI%#JLpSh?6v$i9rg{Tq8MHSeJ;%EVswaN=qoX6BXG~z>IGXeLPvukI?M`I@$7z> z9nDn3_vUuJ&=D<5fQUf{RsuZci3U9)U^HR|KUIqML@`B2Lu11Z71oHCHR!R5x(1P- zc!{Dz@mh&ubBSuTN`7vozo7-PDu$O}@T!Am;nfulDy##XM_APY7QYl`#J`oQj#sG;Rsy259u=)pQzO>!{t3aM!MjXgGnPQ_%^;L~(e+>eWRxK&OUrKTS@?+!| z$7p}O>PRgxCQa#xF{u_#odhsvM+olZ>~qeOZ8F79nd+sBRBJ9mo`_O_ti#gRq`qOv053HzW*d|17*IT-fQ z$wBWZ6Ay)B%?p}tys4$BIrBNl+2}ki_qnn;> zbsD8Q56eb_R2=@s0jXvslkR&+(f}W)$sUrfEHrsVh zZQmHxZD*-)Z}5hofpRWO3|T_Fj_Hh0Asa)R?c#CUUpJHBaUxWBK-*`up!KkBr!4^E)h@IKvtC1)lbu#0@=+h49GUri zW>dleouW_J5CH!JUQMT(b{ZJO0gQ`LTGLc(3sL6IfN!x& zox#yCfIZ@ZHKOt0ON{CU9=mgRQ@fl!VerH6cIZc(?M&+IYqZDRrcS*#$Z?Qx!8n(* zuNX&hpF@+N?P5Qe_;*<2xZ9x#QV^>mF&6ui)3;EpDQ(ZKZbu)_7sStL(2Mtix826` zagJO%0|~dYCqg;wabISyggg#=40wyX9GXl#!w}z)kMWs08M@oRctZ>eG03c?VEj9S zo{)W*pHIqbxKGW;m`r33ZtyyZM>`Wj0fvhm0h- zn5(@3REC!%P?=qUYOxLtT8iqifh)@W=$kLnZDVb1$Up`-r5C-q`pkWjR@7u`Rwfpt zrT^NtHf|3~U=7`aOs><;w7y}6ef-Ue}f#1qFO`{ex#* z_#Wsj7#}0jPR4hH1~$=s5B4dv++#$Gg4tkexX>=-;sA@`=7S!>{>$f!bDd&3SvX(A z9=QLZ*}xST5A!FuJ1Yl}i9fjrQ#M6B(Ocs*+v<%(TDePd6MqSA%3O~dcECTGPVonO M7RRuD_+QTd0n+OWHUIzs literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/num_perms_targets.parquet b/tests/regression/golden/recipe/num_perms_targets.parquet new file mode 100644 index 0000000000000000000000000000000000000000..9ce5dc41dd721fb632d3a75cc3e1ce21b80ff4f7 GIT binary patch literal 4140 zcmcgwJ8#=o6s8nMb_3USf>4kE4T9PTE$SqqEG1FU4x}BuVkx!~ixP{%;L8?el43~8 zlJe+O6z$fr1v+Nv(4k|8qEn`#zoTQ(xl_-%l&l9Sq$z?B$<@8*-1~j!p4UaP&o&r( zh2EsE?$KeIx16J6|l7Jzt_j!h6ReGmae$FZ`xd;ng=)5ONV0ayt- zIl>mub=)JPr>J@W-leHFHZu>v-Jq{ba93j&5La{p055 z6t##E1CM%|=?p{Tm--pv&+-(wM((JFNUuYzu7zXo8k$+}8#U1du|v`8AbZah?Gx2K zQcXuSBwf8m?IqwwM0a_j8(ONZ8LH{dYS+hF;?G&d1oBL@HE{-#nBewYQ3oUTRoif= zXgHpT>uD-Muf+3IWHY{CBOQ*0qrZltFGJ}&>9yC()%X@9C`Ch_QjqFl>XVNm@ic|m zi+t;2zKOAu`xC|=LeUqY^q1lEAECt3%=O_%H{(GJelHS^Z$r`VLg^P5(tn4M!=KH5 z{PjTmxi}E$A4AcfL+O_*>91E1dM23{65CI4HIQQBq)9{x%NNnFLg_y*rhmDJB(urx zhmFj^p2vDF83?Es>%SuD->@_J6Fs^t{e7J0(T@JU8V#q{qW`Q?@vT_I5={l_@|)GC zDa};W;gQ_a`-bVH?j}F`d@GsqmPbUVE=bApY04B06}G45$|(hBk2O_Sj+UxD>LYh# zk>i@_-pb)Ivu{` z`*~kj&Of*1jaxu^&r)TY^$Q%vvHZyHOUt35f+0r5^cLTK&#GF+tGR^O0)SJnHx+B&y9_lQEr1SpL$Nk z*4akm0e{zftztiI+{BMLDY7hk0Dtkj&$2m|J7Ga~!tR_98OP1(2PnLEuCKAX-Wo=N zLi81u9k6f?ei3A+4NF{uilUff4ClzBX(+iel zG<3Dpd{XK5PIxBcRCcOeDLZ5&KJUr49ZfQduJEvO3v{l?=N~9MQ*XB#T@lY|h$mPA zKRj(^^Bsvl1RhqCc)kC~Y`Ty5Vy2z#=<=w7*n>8oKW(?_$4Xfrj4>_AJjm8}h`tZl z1GQPms4Y%MoA4gWZad@gFuGDI!uduAb&}b1q%zoCp0hF2ZdFa8)i@U54g$|;Qt43J z+hH}q&~>TQIG(xB(!Fx22>vTRX>B+1GMp!O%2po>e0^+7o{?HyepWAUH%9-<&Q@n2 zw5pcGH~Wg`2SenWj!-Q0+dSj>!EAJ7L(!FDL08M1E*rWF_w#Hqxemm!BQvvpD`=w8 z?2hsLel_bEStCAo!B-u)Yo?7cF2TJ@!@}MU=Fl{r;P;V`ZDv|o<_z-6Sr+%eeQ(pg zZ2|nGciP5Kf2EC!d*6zkg-n|tVhtTY%-wNa1jYLdeCc2f9hRCSsZ}F69F&((lbL$= zfaswnT)C_-tFORVdp)6;QOZ@TZ8kFka6zxQeeCs$kpML;ZC8k_VhmwVwIiFSq$Yq1 z=G8OBtM4q_>50e}`lNQd6=tu?_G=HTkWZ{OwO!C|CATN|1f zH|`TPnB$u5VeiY~>^}B8E`w9Asd^)}T2!zd)R3m=^+U2`>-eLT7JQGbbbt5bn>X*E znj=WNT|mP1y+dfi&{}c8emwb5$DQ{L#VC$MzKA`BmGxHV<1nq&W?#-WU91`OD(oYC zFdU*Q;k_#3djlU);smzrkWJnrMy8$|L&N#r3DkirA)tUaz;|o>0Rw!WQn92Z+K>CH idtJ7y>*Ntmjvv{i_x#3n_$S@MAL{)LirR<&C-@g$Kw3Tk literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/permute_N1_seed1.parquet b/tests/regression/golden/recipe/permute_N1_seed1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..8a12b574cb60061a0073d6a1dac3f28842a2f30e GIT binary patch literal 15951 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 VPvKt)-{yaUz3~D;KK!rb{}1{-M9=^L literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/permute_N2_seed1.parquet b/tests/regression/golden/recipe/permute_N2_seed1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..8a12b574cb60061a0073d6a1dac3f28842a2f30e GIT binary patch literal 15951 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 VPvKt)-{yaUz3~D;KK!rb{}1{-M9=^L literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/permute_N2_tol0.2_seed1.parquet b/tests/regression/golden/recipe/permute_N2_tol0.2_seed1.parquet new file mode 100644 index 0000000000000000000000000000000000000000..39e99693110690d922f124d50cf993ee2a7ade8c GIT binary patch literal 16984 zcmcg!30PA{*AB9!DvGv=L1{tMs1<|+5NOp&0537vA>@L;J|CKCHEd1t~G zS!l+hWgG2()}m?C%5D{TA(wLe>DZ&KC|v~uafC# zz?fy;!+A5%wS9H(`A(UEzMMDa*(2^OG^YBOT@Az*IkfP476#Y9jlaY361sZW{PjSuom1wFBKmOCdSE3;~slOi@ z_a%Cieyj0t<|=f+ZrRDW)vJ*A|5kN=oVx}c{^Zs-fp%+=horm1=>hA|qPVSrx%_qL z?5s^q!^W;hy=dbH#Sbn=e+`f*Crv3w3+MM=B$aPO>uGhxIr7aY&}s0B6SKZX3>rhq zNOfmu7>*1(hMhBuPPVYY)h0T!g)y3uGf?x7Cwt+BG}%5$V2Eqx<`xoRr zp>%`#IR_T)D*WDtLU(W-`lCI}7CsLQzo5O4Au`t9hpz9zq6-Xtu=Z3bq5Yt8z4o?U zU2Qmaz1Om`=J5v(?9EKxvUl;%)4UF!zFMXgrvJ09`=1rrRiL&P!zTiMuoJV!V4zHI z1xLe4;SkSU{WHwzJ%(G$oQ42C*^fofH1s0yyZY<#t-98CE&Dz1>Kz#T9R@3c(Sg9| z*w+p)va&Q>A72=?JA-L5s-3=tW%wmVe9P4^qJ{=zQNvF4bI)C~0-AKa=^nY|HMez( zSw-)r^}ees;qf^~r`*#$_YO#3A4BKWoaiO?vToAdso6T}MED)uhLE=^3#T^f9G7Oy z_C)#nbyau$g5$^C*Y&QOzW>O%ySkYVKbab~w@H_Me9b2- zYVCR79-UeGXyScc<@~6zkA~jYojx%jXslm@ZqZfW;`F(Vx&r0kO=^!O-QGeqd+X39 z-Pj6`lVKItb+y-rg+E<+N!R!=?dZmmD>`|{nrr*%_PmVyEAQ3xzN6di;>6?5dy0PR zGJN7ue_P&?&&tw%?_tfmzs6^+{}CJBs3>0C_LR{R0zumkSvDs1r!aGd?$;$10|E=9bt zU2i!J{~QTYn;Hs_R-=K62mL>rUyg2^`eSRSXA@EV7w=p+He)iX-8^CY)iJBk#h;ft zRd@IVZFhg-meg%O>iYqsdQ6vH$kAz4p*Zk+=**Cb!dcIDqRNJANg+)osA1BCjIT~@L47N>j$E%=h3=zN4QPH2ZNMMJM{LYuWe3X8%kQN3%Q zPj(JijRyQJ{{85TwdmN?XG^|VwhNV}4EbtZ&HiWPN+rWx$~ZlEnbQ0(}F{P z8Czo^QIQs4(Mer#qM}L($1ph`SjVvJO9piM$-nD6VQ@0|b%yNh;p_|c#jE{Gq>3;5 zFRvJMuG-W2C~Q1b|DYY{mbO7sfNJj+j}X=`MT=Ki-M&BfJ5=O1^~{0&MX1*2+~8$a z)rhv^Qm>Ou)6n?S9ZuW!s7BHI9#`9Zrb9Q@-+nrH>o#=quMoc?yYEqvXV`}IoxVh& zGoEz+@M(kN#4FXuAUs zN`795YL7_2J)N}%Es#2Fez)u!L}Q$pxBB!~XjbSR;mYd=kfu0iqjJX%L>nIw^j1(9 ziaw%Pc&*c6bTP3<LFD7^j;tJl}a>q zvfC|r(~lPXgKS~ZN!_u3P^InbA9pwOW6qA>hR*-FWlFce-KhJOTTgaA{tg{mG^BIC zZx*3Ffwj`7hilOjmnv1sp&E2)q5bS%Tehcf(yS!1J=w`T1^l*gy)f(qI#$SA(c`a!=-oe^ zM*elB2E{(FD;d6K4LVwm_7wR<-zoiwH6IJ=X`dG7(M^_%L@@=sg`TDxyVG2hM%P269HCXBm> zu4*=+t*Ca#5Ak)V2hHZW{nPzO@Xw5f238?u+B>w;(3=lroH3ni9sTfFSRJQSVk5@8p= z2#vhDmVfEJ*~q&8kb(t|K1SV-l}islEz-q5KYZiJwqkVTpG(o-P0B)@cFjn?U^f%3 zdVGCqALcX^)NR)#HS05EpSXPbs`Il@#c$(Mye}_A*HhHm=?9jgEql&u4*k0vHQky& zZS>S~v^9EP=~>5R=;~_6$x)kEBft8~c1wD#MO7gS4(I*58vT7ganO_Z*Pw-k&S8m- z8&K+=M;^ZpUX6-|-CFPXRRvyNGXh1nu;`@jczLN(!iFX%u24?MylK&~(rrsSYyR>5 zjyc+kIZ$4<^X$Y4TMN{4@1M}y_c`!J(!K`Xh?ybgj+hAoL&V&%zYTCiI`Bgla7cnt z68sQzNz5NH%S?fSc_rqKeH;>KyWyxrhk|)yPblXcTnX;@0G@$A4*l0~3?exsRdp&$ z#Hv%JucSJQ;Y=PjJG&En(csG#PMmbo&w(!*dJ&kW?5;hTBE|7H9jJ6*yEN%`qr`!K zYa4Xd-|KXzlNxkZBkop3t-YrU{`8}oZI|xqM#jFoV*4;UZ{)kPhc*=1^KQB+8POdZ zb=yAd(NB5fj&68w-3I%A8+AQb?dg2n^Rh1b;rh=WYnpV=ep(pe+^0!*{@rB%8xi+( zVf}ZP9I(2ltNG?verHaDu7^<9d7bFGZuGT>4uy1E9@l4m{`)%{bYE9k*ZHJALv=2f zGoPQf;yL=vU&!8K&9nRUn}f{g3UpsSe!R!c-%#mSW2>g`y^98S%k5Hs_ZH&p=vI^x z`9CydSlE!kCywfDe3RXPCiZJ_bAuGdQr`0I1Ce>lMQ<@H&E}VMd2p9Oilw zD45-02Im6S+X0rdfr8l^6Uwh(JkOByJFbA?fafv~8#n{a-Sz2+XmWrMu&Qyqv(D6H+ z$R+mCjjOg%Pnm3*Goz!EFU`); z$#}gP9he$si9K_bJ^O$yoBndw$WHFCwbK|KXz=9-KSKxFIk9OP@Cl4&V?Z-=CIYd{ zmf2vc8LS~LT8LZy{x4otgE^kkXdZSzTA_&vnSTg__B{OD)VFqi`tmv9V1OnzRRxe z1)5NEHeMsD^gq{#bO|!65m?*l%vw5oDw|#I0$9|G9E?Skz9g2x_w-l?2D5MU2Q2EP z0LG$9UlL2nHP*|IUG4=~RDy}IsM7z$BK_PX-xGkdIxz28vA+#q-|)w-K;eyC%T$u; zCU?4+xCyyxWU80y<9Menv(}bfJDh#y1JIAku&{nq>3`}+hM4I#MJNb}53Jc$0(L16 z5ZgW7l6#R!wq0q(EVE)C4q|^6gpJxvXiTUL)r75NTk2j)D5g)g*GI6okH8?Wk!;B| zGWffGjkbrgj|79osAK@I5mox1Yt-z6#+yB%i9XdHj$qG;z?!r}swG#&B-83W;mjy@ zbtLH3?wOXvq$b)6G3?41tl29iTH_Oqkw^bLnq3tOdQdYeULUITh3mt7JeH|p)0t&L z*f3dk8nIdm|NW$q_9*ex4EE8s01%-~<&-ZGQT zz%sr`mv*0lrHC?^=8fzYhOwJ>1B*I0x3;^w#v6e}XyXoM=q$*QOfae_#hz>W0@Q{b^g8c}>+ zdIHxsRT3Wu7<@%yLU@`uM9CEi1JY%IVR+2k#CYHI#Du66X{a*SFqTRZ5|AnlO7zl? z>iS34M-J%Ca zDGdn*U1L*mn?xFguwm)F!I(lx?u5QlgGhO5($_z=o~aH78M}Q6_rF z5PAc?HaS#jZ1)0bsNN=$c%fHfd~B*TWCZpN&?_%11^h#nAD*2U&oSs0oS(=|)ksys z6j4HIt|%c~1Mg)^^=qOM#|yK-c5Ur%$q(M#eAV(cti4F@$7(?;_)BUs$9P>uSrTrr zS5#W7wlwqMmtA{K4#a3y8`fPb;l?T@t;fZP-blnL^)r>3P1_O|;G5!vSiBEs zi#P!qQEU0ya=$m9r`3>GR8l3Z8F@ya+K$@C(!9)aFg2&V$~gk|@O%+h2)=G|Hh9q- zP0kyZ*4ZStQTJuzGe(>8iSZo&>kQFqUyCu^zIo3G+b20h2sxSq=NSE-YT;al_YgIl z^(>t?EUkb0?tLvjN9K|jtvT7p(!AcVU%vS4&|-6=e*d3qZGIN8kh7cTXx`W2JllD% zYguP5_?srNX(-rCE%Xv45WLb4 z^9JCR`EaiBl6Z$R$k56C7sR9YA(B8{IN z`B6At=zI6=8@YLzrWjzdSIJeE&u30cUrOGz9Kv;(gT#TqR*MKED9FW*GUt z0Y4E>&B&gixZZr0l?Ub<`Umo(M+bO%`a|o8AczKdiEstt5m)uDE@(cV^P~I>{loZS z(SCaS$3znT7((=K@n<56LYg@TKQPD8UoMFB%rS{4i~eVH&qU-#V)!tuFP<-7pbvu4 zhWdy&V$y+xv}OK;5dluaLG8#|M|FJM64VKeC9lrGW#&UeYJ)_C{DjC#Az#NEtBNxPx@3#_PADO+cl!XeE zz?Zcm;OC%EBz|83IfEF_5MKnSQNALK3^}Ix{s`_AMe!WW&++is|DX#tT@as~k0F0% z7~1g<{*1z3M)oj@H91c3kSdcQ-;*DGK83*nNRC|={F_L$F)dDS@Mm*>;A3bbe}H>V z{ium}d~jhRbU+lwr?Ff$;>-!zGOb6lM&m(T9J?AWhF$en#_+eN@K?gG`HxoZPNPM` I|5M`s03L-x7XSbN literal 0 HcmV?d00001 diff --git a/tests/regression/golden/recipe/remove_duplicates.parquet b/tests/regression/golden/recipe/remove_duplicates.parquet new file mode 100644 index 0000000000000000000000000000000000000000..5634dd0eb7a0e28bc77b9db52810d6ebb05c873a GIT binary patch literal 15320 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 literal 0 HcmV?d00001 diff --git a/tests/regression/test_invariance_recipe.py b/tests/regression/test_invariance_recipe.py new file mode 100644 index 00000000..78c1113b --- /dev/null +++ b/tests/regression/test_invariance_recipe.py @@ -0,0 +1,272 @@ +"""Golden-output regression tests for the recipe construction functions. + +Covers :mod:`stitches.fx_recipe`, the most algorithmically involved part of the +package. `permute_stitching_recipes` samples archive points per target window +while enforcing several non-local constraints (no duplicate archive points, no +envelope collapse across generated realizations), and the transition handlers +then rewrite window boundaries. Small changes here are easy to make by accident +and hard to notice, which is exactly what these goldens are for. + +All randomized calls pass an explicit ``seed`` so the recorded artifacts are +reproducible. Tests that must not depend on a particular draw assert structural +properties instead of recorded values. +""" + +import pandas as pd +import pytest + +from stitches.fx_match import match_neighborhood +from stitches.fx_recipe import ( + get_num_perms, + handle_final_period, + handle_transition_periods, + permute_stitching_recipes, + remove_duplicates, +) + +# The synthetic target/archive pair used by the existing unit tests. Reused here +# so the regression suite and the unit suite describe the same scenario. +from tests.test_fx_recipe import TestRecipe + +TARGET_DATA = TestRecipe.TARGET_DATA +ARCHIVE_DATA = TestRecipe.ARCHIVE_DATA + + +@pytest.fixture(scope="module") +def matched(): + """Return matched data at the tolerance used by the existing unit tests.""" + return match_neighborhood(TARGET_DATA, ARCHIVE_DATA, tol=0.07) + + +@pytest.fixture(scope="module") +def matched_wide(): + """Return matched data at a wider tolerance, giving more candidates per window.""" + return match_neighborhood(TARGET_DATA, ARCHIVE_DATA, tol=0.2) + + +@pytest.fixture(scope="module") +def matched_nn(): + """Return nearest-neighbor matches: exactly one archive point per target year. + + ``remove_duplicates`` requires singular matches and raises `TypeError` + otherwise, so it must be fed ``tol=0`` output rather than a permuted recipe. + """ + return match_neighborhood(TARGET_DATA, ARCHIVE_DATA, tol=0.0) + + +# --------------------------------------------------------------------------- +# get_num_perms +# --------------------------------------------------------------------------- + + +def test_get_num_perms_targets_invariant(golden, matched): + """The per-target permutation summary is unchanged. + + ``get_num_perms`` decides how many collapse-free realizations each target can + support, which in turn drives the order targets are processed in. A change + here reorders the whole construction. + """ + targets, _ = get_num_perms(matched) + golden.assert_frame(targets, "recipe/num_perms_targets", tolerance="exact") + + +def test_get_num_perms_guide_invariant(golden, matched): + """The per-window permutation guide is unchanged.""" + _, guide = get_num_perms(matched) + golden.assert_frame(guide, "recipe/num_perms_guide", tolerance="exact") + + +def test_get_num_perms_counts_are_positive(matched): + """Every target window has at least one candidate match.""" + _, guide = get_num_perms(matched) + count_column = "n_matches" if "n_matches" in guide.columns else guide.columns[-1] + + assert (guide[count_column] > 0).all() + + +# --------------------------------------------------------------------------- +# remove_duplicates +# --------------------------------------------------------------------------- + + +def test_remove_duplicates_invariant(golden, matched_nn): + """`remove_duplicates` output is unchanged. + + Where two target years claim the same archive point, the closer one keeps it + and the other is re-matched against an archive with the taken points removed. + This pins which target year wins and what the loser is re-matched to. + """ + out = remove_duplicates(md=matched_nn.copy(), archive=ARCHIVE_DATA) + golden.assert_frame(out, "recipe/remove_duplicates", tolerance="distance") + + +def test_remove_duplicates_leaves_no_repeated_archive_point(matched_nn): + """No archive point is used twice after deduplication.""" + out = remove_duplicates(md=matched_nn.copy(), archive=ARCHIVE_DATA) + archive_cols = [c for c in out.columns if c.startswith("archive_")] + + assert not out[archive_cols].duplicated().any() + + +def test_remove_duplicates_preserves_target_coverage(matched_nn): + """Re-matching never drops a target year.""" + out = remove_duplicates(md=matched_nn.copy(), archive=ARCHIVE_DATA) + + assert set(out["target_year"]) == set(matched_nn["target_year"]) + + +def test_remove_duplicates_rejects_multiple_matches_per_year(matched): + """Feeding non-singular matches raises `TypeError`. + + Documents a real precondition of the function: it operates on one recipe at a + time, not on the full many-to-many match table. + """ + with pytest.raises(TypeError): + remove_duplicates(md=matched.copy(), archive=ARCHIVE_DATA) + + +# --------------------------------------------------------------------------- +# permute_stitching_recipes +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("n_matches", [1, 2]) +def test_permute_stitching_recipes_invariant(golden, matched, n_matches): + """Seeded recipe permutations are unchanged.""" + out = permute_stitching_recipes( + N_matches=n_matches, matched_data=matched, archive=ARCHIVE_DATA, seed=1 + ) + golden.assert_frame(out, f"recipe/permute_N{n_matches}_seed1", tolerance="distance") + + +def test_permute_stitching_recipes_wide_tolerance_invariant(golden, matched_wide): + """Seeded permutations at a wider tolerance are unchanged. + + A wider tolerance means more candidates per window, exercising the sampling + and duplicate-rejection loop far more heavily than the narrow case. + """ + out = permute_stitching_recipes( + N_matches=2, matched_data=matched_wide, archive=ARCHIVE_DATA, seed=1 + ) + golden.assert_frame(out, "recipe/permute_N2_tol0.2_seed1", tolerance="distance") + + +def test_permute_covers_every_target_window(matched): + """A generated recipe spans exactly the target's time windows.""" + out = permute_stitching_recipes( + N_matches=1, matched_data=matched, archive=ARCHIVE_DATA, seed=1 + ) + + assert set(out["target_year"]) == set(TARGET_DATA["year"]) + + +def test_permute_draws_only_from_the_archive(matched): + """Every selected window really exists in the archive.""" + out = permute_stitching_recipes( + N_matches=2, matched_data=matched, archive=ARCHIVE_DATA, seed=1 + ) + + archive_cols = [c for c in out.columns if c.startswith("archive_")] + check = out[archive_cols].copy() + check.columns = [c.replace("archive_", "") for c in check.columns] + + assert len(check.merge(ARCHIVE_DATA)) == len(check), ( + "recipe contains a window that is not present in the archive" + ) + + +def test_permute_uses_each_archive_point_once(matched): + """No archive point is reused within a generated realization.""" + out = permute_stitching_recipes( + N_matches=2, matched_data=matched, archive=ARCHIVE_DATA, seed=1 + ) + + archive_cols = [c for c in out.columns if c.startswith("archive_")] + for _, group in out.groupby("stitching_id"): + assert not group[archive_cols].duplicated().any(), ( + "an archive point is reused within one stitching_id" + ) + + +def test_permute_has_no_envelope_collapse(matched_wide): + """Distinct target realizations do not share an archive point in the same year. + + "Envelope collapse" is the failure mode where independently generated + realizations converge onto identical archive data, understating the spread of + the emulated ensemble. Guarding it is a core correctness property. + """ + target2 = pd.concat([ARCHIVE_DATA, TARGET_DATA]).reset_index(drop=True).copy() + archive2 = target2.copy() + + out = permute_stitching_recipes( + N_matches=2, + matched_data=match_neighborhood(target2, archive2, tol=0.2), + archive=archive2, + seed=1, + ) + + archive_cols = [c for c in out.columns if c.startswith("archive_")] + for year in out["target_year"].unique(): + window = out[out["target_year"] == year] + assert len(window) == len(window[archive_cols].drop_duplicates()), ( + f"envelope collapse at target_year={year}" + ) + + +def test_permute_rejects_multiple_target_experiments(matched): + """Mixing target experiments in one call is rejected.""" + mixed = matched.copy() + mixed.loc[mixed.index[: len(mixed) // 2], "target_experiment"] = "ssp585" + + with pytest.raises(TypeError): + permute_stitching_recipes( + N_matches=1, matched_data=mixed, archive=ARCHIVE_DATA, seed=1 + ) + + +# --------------------------------------------------------------------------- +# transition handling +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def messy_recipe(matched): + """Return a seeded unformatted recipe for the transition handlers.""" + return permute_stitching_recipes( + N_matches=1, matched_data=matched, archive=ARCHIVE_DATA, seed=1 + ) + + +def test_handle_transition_periods_invariant(golden, messy_recipe): + """`handle_transition_periods` output is unchanged. + + This function splits windows that straddle the historical/future boundary, so + an error produces recipes that request data from the wrong experiment. + """ + out = handle_transition_periods(messy_recipe.copy()) + golden.assert_frame(out, "recipe/handle_transition_periods", tolerance="distance") + + +def test_handle_final_period_invariant(golden, messy_recipe): + """`handle_final_period` output is unchanged.""" + out = handle_final_period(handle_transition_periods(messy_recipe.copy())) + golden.assert_frame(out, "recipe/handle_final_period", tolerance="distance") + + +def test_transition_handling_preserves_target_coverage(messy_recipe): + """Transition handling never leaves a gap in the target timeline. + + The union of target windows must still cover the original span contiguously; + a gap would silently produce a stitched series with missing years. + """ + out = handle_final_period(handle_transition_periods(messy_recipe.copy())) + + for _, group in out.groupby("stitching_id"): + ordered = group.sort_values("target_start_yr") + starts = ordered["target_start_yr"].tolist() + ends = ordered["target_end_yr"].tolist() + + for previous_end, next_start in zip(ends, starts[1:]): + assert next_start == previous_end + 1, ( + f"gap or overlap in target coverage: {previous_end} -> {next_start}" + ) diff --git a/tests/regression/test_invariance_util.py b/tests/regression/test_invariance_util.py new file mode 100644 index 00000000..37a6f103 --- /dev/null +++ b/tests/regression/test_invariance_util.py @@ -0,0 +1,187 @@ +"""Golden-output regression tests for the utility and data helpers. + +Covers :mod:`stitches.fx_util` and :mod:`stitches.fx_data`. These functions are +small and cheap, but `global_mean` in particular carries real scientific weight: +it applies the cosine-of-latitude area weighting that converts a gridded field +into a global mean temperature. An error there would bias every emulated series +while still producing entirely plausible-looking numbers, so it is verified +against an analytically known result rather than only against a recorded one. +""" + +import numpy as np +import pandas as pd +import pytest +import xarray as xr + +from stitches.fx_data import get_lat_name, global_mean +from stitches.fx_util import anti_join, combine_df, nrow, selstr + + +# --------------------------------------------------------------------------- +# fx_util +# --------------------------------------------------------------------------- + + +def test_selstr_extracts_substrings(): + """`selstr` slices a fixed character range out of a string.""" + assert selstr("abcdef", 0, 3) == "abc" + assert selstr("abcdef", 2, 4) == "cd" + assert selstr("abcdef", 0, 6) == "abcdef" + + +def test_nrow_counts_rows(): + """`nrow` reports the row count for frames and the length for arrays.""" + assert nrow(pd.DataFrame({"a": [1, 2, 3]})) == 3 + assert nrow(pd.DataFrame({"a": []})) == 0 + assert nrow(np.array([1, 2, 3, 4])) == 4 + + +def test_combine_df_invariant(golden): + """`combine_df` cross-join output is unchanged.""" + left = pd.DataFrame({"a": [1, 2], "b": ["x", "y"]}) + right = pd.DataFrame({"c": [10, 20, 30]}) + + out = combine_df(left, right) + golden.assert_frame(out, "util/combine_df", tolerance="exact") + + +def test_combine_df_produces_cartesian_product(): + """`combine_df` yields one row per input pair.""" + left = pd.DataFrame({"a": [1, 2], "b": ["x", "y"]}) + right = pd.DataFrame({"c": [10, 20, 30]}) + + out = combine_df(left, right) + + assert len(out) == len(left) * len(right) + + +def test_combine_df_rejects_shared_columns(): + """Overlapping column names are rejected rather than silently suffixed.""" + frame = pd.DataFrame({"a": [1, 2]}) + + with pytest.raises(Exception): + combine_df(frame, frame) + + +def test_anti_join_invariant(golden): + """`anti_join` output is unchanged.""" + left = pd.DataFrame({"k": [1, 2, 3, 4], "v": ["a", "b", "c", "d"]}) + right = pd.DataFrame({"k": [2, 4], "v": ["b", "d"]}) + + out = anti_join(left, right, bycols=["k"]) + golden.assert_frame(out, "util/anti_join", tolerance="exact") + + +def test_anti_join_keeps_only_unmatched_rows(): + """`anti_join` retains exactly the left rows with no match on the right.""" + left = pd.DataFrame({"k": [1, 2, 3, 4], "v": ["a", "b", "c", "d"]}) + right = pd.DataFrame({"k": [2, 4], "v": ["b", "d"]}) + + out = anti_join(left, right, bycols=["k"]) + + assert sorted(out["k"].tolist()) == [1, 3] + + +def test_anti_join_with_no_overlap_returns_left(): + """With no shared keys, every left row survives.""" + left = pd.DataFrame({"k": [1, 2], "v": ["a", "b"]}) + right = pd.DataFrame({"k": [98, 99], "v": ["y", "z"]}) + + out = anti_join(left, right, bycols=["k"]) + + assert len(out) == len(left) + + +# --------------------------------------------------------------------------- +# fx_data +# --------------------------------------------------------------------------- + + +def _grid(lat_name="lat", nlat=19, nlon=36, ntime=3): + """Build a small synthetic gridded dataset with a regular lat/lon grid. + + :param lat_name: Name to give the latitude coordinate, so the ``lat`` and + ``latitude`` spellings can both be exercised. + :return: A Dataset with a single ``tas`` variable. + :rtype: xarray.Dataset + """ + lats = np.linspace(-90, 90, nlat) + lons = np.linspace(0, 350, nlon) + times = pd.date_range("2000-01-01", periods=ntime, freq="MS") + + # A latitude-dependent field: warm at the equator, cold at the poles. This + # makes the area weighting matter -- an unweighted mean would over-count the + # poles, where grid cells are much smaller. + field = np.empty((ntime, nlat, nlon)) + for t in range(ntime): + field[t] = np.cos(np.deg2rad(lats))[:, None] * np.ones((1, nlon)) + t + + return xr.Dataset( + {"tas": ((("time", lat_name, "lon")), field)}, + coords={"time": times, lat_name: lats, "lon": lons}, + ) + + +@pytest.mark.parametrize("lat_name", ["lat", "latitude"]) +def test_get_lat_name_accepts_both_spellings(lat_name): + """`get_lat_name` finds the latitude coordinate under either name.""" + ds = _grid(lat_name=lat_name) + + assert get_lat_name(ds) == lat_name + + +def test_get_lat_name_raises_without_latitude(): + """A dataset with no recognizable latitude coordinate raises `RuntimeError`.""" + ds = xr.Dataset({"tas": (("time",), [1.0, 2.0])}, coords={"time": [0, 1]}) + + with pytest.raises(RuntimeError): + get_lat_name(ds) + + +def test_global_mean_invariant(golden): + """`global_mean` output is unchanged.""" + ds = _grid() + out = global_mean(ds) + + frame = out["tas"].to_dataframe().reset_index() + frame["time"] = frame["time"].astype(str) + golden.assert_frame(frame, "data/global_mean", tolerance="value") + + +def test_global_mean_reduces_to_time_only(): + """`global_mean` collapses every dimension except time.""" + ds = _grid() + out = global_mean(ds) + + assert set(out["tas"].dims) == {"time"} + + +def test_global_mean_of_constant_field_is_that_constant(): + """Averaging a spatially uniform field returns the constant. + + An analytic check on the weighting: whatever the weights are, if they are + correctly normalized then a constant field must average to itself. A bug in + the normalization shows up here immediately. + """ + ds = _grid() + ds["tas"] = xr.full_like(ds["tas"], 42.0) + + out = global_mean(ds) + + np.testing.assert_allclose(out["tas"].values, 42.0, rtol=1e-12) + + +def test_global_mean_weights_by_latitude(): + """The weighted mean of cos(latitude) exceeds the unweighted mean. + + With a cos(lat) field, area weighting emphasizes the wide equatorial cells + where the field is largest, so the weighted result must be strictly greater + than a naive arithmetic mean over grid cells. This confirms weighting is + actually applied rather than silently skipped. + """ + ds = _grid() + + weighted = float(global_mean(ds)["tas"].isel(time=0)) + unweighted = float(ds["tas"].isel(time=0).mean()) + + assert weighted > unweighted From f2089bcdf8c5ff23506ad5a2dadcf04b485eb714 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:19:40 -0400 Subject: [PATCH 08/14] test(bench): add pytest-benchmark suite and record the baseline Adds 33 benchmarks over fx_match, fx_processing, and fx_recipe, parametrized by input size so they reveal SCALING rather than a single opaque number. Scaling is what identifies worthwhile optimization targets; absolute timings on a shared runner do not. Findings from the recorded baseline, now documented in plans/benchmarks-and-regression-testing.md section 0: - match_neighborhood is the top target. It is both the most expensive path and slightly superlinear in target windows: 28 -> 112 -> 280 windows costs 59.5ms -> 234.7ms -> 646.5ms, i.e. 10.9x cost for 10x input. - The bottleneck is iterating target groups, not searching the archive. Widening the archive 8x (2 -> 16 ensemble members) costs only 1.6x. Optimization should therefore attack the per-group Python loop, not the lookup. - calculate_rolling_mean is dominated by GROUP count, not row count, at roughly 450-700us fixed overhead per group, and is entirely insensitive to window size (3/9/21 all ~9.65ms). That points squarely at the groupby.transform lambda. - get_chunk_info costs ~700us per chunk, spent building a scikit-learn LinearRegression per chunk and growing the frame by repeated pd.concat. A closed-form slope would remove most of it, and the golden artifacts committed earlier make that safe to attempt. - permute_stitching_recipes saturates in N_matches on the synthetic archive (N=2 and N=5 are indistinguishable because the archive cannot support five collapse-free realizations) but scales cleanly with tolerance. Noted so the saturation is not later mistaken for an optimization win. Benchmarks are excluded from the default pytest run via testpaths, and bench_*.py was added to python_files so ============================= test session starts ============================== platform darwin -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0 rootdir: /Users/d3y010/repos/github/stitches configfile: pytest.ini plugins: anyio-4.9.0, asyncio-1.3.0, logfire-4.21.0, hypothesis-6.152.9, langsmith-0.3.33 asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 0 items / 3 errors ==================================== ERRORS ==================================== __________________ ERROR collecting benchmarks/bench_match.py __________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_match.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_match.py:12: in from stitches.fx_match import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' _______________ ERROR collecting benchmarks/bench_processing.py ________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_processing.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_processing.py:11: in from stitches.fx_processing import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' _________________ ERROR collecting benchmarks/bench_recipe.py __________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_recipe.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_recipe.py:17: in from stitches.fx_recipe import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' =========================== short test summary info ============================ ERROR benchmarks/bench_match.py ERROR benchmarks/bench_processing.py ERROR benchmarks/bench_recipe.py !!!!!!!!!!!!!!!!!!! Interrupted: 3 errors during collection !!!!!!!!!!!!!!!!!!!! ============================== 3 errors in 0.95s =============================== collects them when the path is given explicitly. Verified: bare ============================= test session starts ============================== platform darwin -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0 rootdir: /Users/d3y010/repos/github/stitches configfile: pytest.ini testpaths: tests plugins: anyio-4.9.0, asyncio-1.3.0, logfire-4.21.0, hypothesis-6.152.9, langsmith-0.3.33 asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 0 items / 11 errors ==================================== ERRORS ==================================== __________ ERROR collecting tests/regression/test_invariance_match.py __________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/regression/test_invariance_match.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/regression/test_invariance_match.py:11: in from stitches.fx_match import ( E ModuleNotFoundError: No module named 'stitches' _______ ERROR collecting tests/regression/test_invariance_processing.py ________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/regression/test_invariance_processing.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/regression/test_invariance_processing.py:18: in from stitches.fx_processing import ( E ModuleNotFoundError: No module named 'stitches' _________ ERROR collecting tests/regression/test_invariance_recipe.py __________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/regression/test_invariance_recipe.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/regression/test_invariance_recipe.py:18: in from stitches.fx_match import match_neighborhood E ModuleNotFoundError: No module named 'stitches' __________ ERROR collecting tests/regression/test_invariance_util.py ___________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/regression/test_invariance_util.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/regression/test_invariance_util.py:16: in from stitches.fx_data import get_lat_name, global_mean E ModuleNotFoundError: No module named 'stitches' ___________________ ERROR collecting tests/test_fx_recipe.py ___________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_fx_recipe.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_fx_recipe.py:6: in from stitches.fx_match import match_neighborhood E ModuleNotFoundError: No module named 'stitches' _________________ ERROR collecting tests/test_install_data.py __________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_install_data.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_install_data.py:3: in import stitches E ModuleNotFoundError: No module named 'stitches' _____________________ ERROR collecting tests/test_match.py _____________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_match.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_match.py:6: in from stitches.fx_match import ( E ModuleNotFoundError: No module named 'stitches' ____________________ ERROR collecting tests/test_pangeo.py _____________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_pangeo.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_pangeo.py:14: in from stitches.fx_pangeo import fetch_nc, fetch_pangeo_table E ModuleNotFoundError: No module named 'stitches' _____________________ ERROR collecting tests/test_seed.py ______________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_seed.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_seed.py:21: in from stitches.fx_match import match_neighborhood, shuffle_function E ModuleNotFoundError: No module named 'stitches' ____________________ ERROR collecting tests/test_stitch.py _____________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_stitch.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_stitch.py:22: in from stitches.fx_pangeo import fetch_nc E ModuleNotFoundError: No module named 'stitches' _____________________ ERROR collecting tests/test_util.py ______________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/tests/test_util.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) tests/test_util.py:7: in from stitches.fx_util import ( E ModuleNotFoundError: No module named 'stitches' =========================== short test summary info ============================ ERROR tests/regression/test_invariance_match.py ERROR tests/regression/test_invariance_processing.py ERROR tests/regression/test_invariance_recipe.py ERROR tests/regression/test_invariance_util.py ERROR tests/test_fx_recipe.py ERROR tests/test_install_data.py ERROR tests/test_match.py ERROR tests/test_pangeo.py ERROR tests/test_seed.py ERROR tests/test_stitch.py ERROR tests/test_util.py !!!!!!!!!!!!!!!!!!! Interrupted: 11 errors during collection !!!!!!!!!!!!!!!!!!! ============================== 11 errors in 0.71s ============================== collects 98 tests and no benchmarks; ============================= test session starts ============================== platform darwin -- Python 3.13.3, pytest-8.3.5, pluggy-1.6.0 rootdir: /Users/d3y010/repos/github/stitches configfile: pytest.ini plugins: anyio-4.9.0, asyncio-1.3.0, logfire-4.21.0, hypothesis-6.152.9, langsmith-0.3.33 asyncio: mode=Mode.STRICT, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function collected 0 items / 3 errors ==================================== ERRORS ==================================== __________________ ERROR collecting benchmarks/bench_match.py __________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_match.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_match.py:12: in from stitches.fx_match import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' _______________ ERROR collecting benchmarks/bench_processing.py ________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_processing.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_processing.py:11: in from stitches.fx_processing import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' _________________ ERROR collecting benchmarks/bench_recipe.py __________________ ImportError while importing test module '/Users/d3y010/repos/github/stitches/benchmarks/bench_recipe.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: ../../../.pyenv/versions/3.13.3/lib/python3.13/importlib/__init__.py:88: in import_module return _bootstrap._gcd_import(name[level:], package, level) benchmarks/bench_recipe.py:17: in from stitches.fx_recipe import ( stitches/__init__.py:11: in from .fx_pangeo import fetch_nc, fetch_pangeo_table stitches/fx_pangeo.py:8: in import intake E ModuleNotFoundError: No module named 'intake' =========================== short test summary info ============================ ERROR benchmarks/bench_match.py ERROR benchmarks/bench_processing.py ERROR benchmarks/bench_recipe.py !!!!!!!!!!!!!!!!!!! Interrupted: 3 errors during collection !!!!!!!!!!!!!!!!!!!! ============================== 3 errors in 0.29s =============================== collects exactly 33. All benchmark calls into randomized code pass an explicit seed so the amount of work performed is identical run to run; otherwise retry counts inside the recipe-permutation while loop would make the timings uncomparable. --- benchmarks/__init__.py | 31 +++++ benchmarks/bench_match.py | 87 ++++++++++++ benchmarks/bench_processing.py | 82 ++++++++++++ benchmarks/bench_recipe.py | 125 +++++++++++++++++ benchmarks/conftest.py | 149 +++++++++++++++++++++ plans/benchmarks-and-regression-testing.md | 110 +++++++++++++++ pytest.ini | 8 +- 7 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/bench_match.py create mode 100644 benchmarks/bench_processing.py create mode 100644 benchmarks/bench_recipe.py create mode 100644 benchmarks/conftest.py diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 00000000..de1ac485 --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1,31 @@ +"""Performance benchmarks for the stitches package. + +These are **not** correctness tests. They measure wall-clock time so that +performance work can be shown to help, and so that a refactor intended to be +neutral can be shown not to have made things slower. + +Correctness during optimization is the job of ``tests/regression``: the two +suites are meant to be used together. A performance change is acceptable only +when the benchmarks improve *and* the golden-output suite still passes. + +Running +------- + +Benchmarks are excluded from the default ``pytest`` run. To use them:: + + # Record a baseline, ideally on a tagged commit + pytest benchmarks --benchmark-only --benchmark-save=baseline + + # Compare a change against that baseline and fail on a big regression + pytest benchmarks --benchmark-only \\ + --benchmark-compare=baseline \\ + --benchmark-compare-fail=mean:25% + +Interpretation +-------------- + +Run benchmarks on one fixed OS and Python version. Comparing numbers across +machines, or across matrix jobs, is meaningless -- shared CI runners vary by +well over the regression threshold from run to run. Treat any benchmark whose +mean is under roughly 10ms as indicative only. +""" diff --git a/benchmarks/bench_match.py b/benchmarks/bench_match.py new file mode 100644 index 00000000..ee978aac --- /dev/null +++ b/benchmarks/bench_match.py @@ -0,0 +1,87 @@ +"""Benchmarks for the matching functions. + +`match_neighborhood` is the primary optimization target identified in the +development plan: it loops in Python over each target window group and calls +`internal_dist` per group, then concatenates. The parametrized archive sizes here +exist to reveal how that cost scales, which is what justifies (or fails to +justify) vectorizing it. +""" + +import pytest + +from stitches.fx_match import ( + drop_hist_false_duplicates, + internal_dist, + match_neighborhood, + shuffle_function, +) + +from .conftest import make_archive, make_chunk_frame + + +@pytest.mark.parametrize("tol", [0.0, 0.1, 0.5]) +def test_bench_match_neighborhood_example(benchmark, example_target, example_archive, tol): + """Benchmark `match_neighborhood` on the committed example data.""" + result = benchmark(match_neighborhood, example_target, example_archive, tol=tol) + + assert len(result) > 0 + + +@pytest.mark.parametrize("n_windows", [28, 112, 280]) +def test_bench_match_neighborhood_scaling(benchmark, n_windows): + """Benchmark `match_neighborhood` as the number of target windows grows. + + The window counts correspond to roughly 1x, 4x, and 10x a single CMIP6 + trajectory. Comparing the three reveals whether the per-group Python loop + scales linearly or worse. + """ + target = make_chunk_frame(n_windows) + archive = make_archive(n_windows, n_members=4) + + result = benchmark(match_neighborhood, target, archive, tol=0.1) + + assert len(result) > 0 + + +@pytest.mark.parametrize("n_members", [2, 8, 16]) +def test_bench_match_neighborhood_archive_width(benchmark, n_members): + """Benchmark `match_neighborhood` as the archive gains ensemble members. + + Archive size grows independently of target size, so this isolates the cost of + searching a wider archive from the cost of iterating more target windows. + """ + target = make_chunk_frame(28) + archive = make_archive(28, n_members=n_members) + + result = benchmark(match_neighborhood, target, archive, tol=0.1) + + assert len(result) > 0 + + +def test_bench_internal_dist(benchmark, example_target): + """Benchmark `internal_dist`, the innermost distance computation.""" + result = benchmark( + internal_dist, example_target.fx[0], example_target.dx[0], example_target + ) + + assert len(result) > 0 + + +def test_bench_drop_hist_false_duplicates(benchmark, example_match_with_duplicates): + """Benchmark `drop_hist_false_duplicates`. + + Another per-group loop with a terminal `pd.concat`, and a candidate for the + same vectorization treatment as `match_neighborhood`. + """ + result = benchmark( + drop_hist_false_duplicates, example_match_with_duplicates.copy() + ) + + assert len(result) > 0 + + +def test_bench_shuffle_function(benchmark, multi_member_series): + """Benchmark `shuffle_function` on a large frame.""" + result = benchmark(shuffle_function, multi_member_series, seed=1) + + assert len(result) == len(multi_member_series) diff --git a/benchmarks/bench_processing.py b/benchmarks/bench_processing.py new file mode 100644 index 00000000..0bad7967 --- /dev/null +++ b/benchmarks/bench_processing.py @@ -0,0 +1,82 @@ +"""Benchmarks for the time-series processing functions. + +`calculate_rolling_mean` and `chunk_ts` run over the entire CMIP6 archive during +package-data generation, which the documentation notes takes several hours. They +are therefore the functions where a constant-factor improvement translates into +the largest absolute saving. +""" + +import pytest + +from stitches.fx_processing import ( + calculate_rolling_mean, + chunk_ts, + get_chunk_info, + subset_archive, +) + +from .conftest import make_series + + +@pytest.mark.parametrize("size", [3, 9, 21]) +def test_bench_calculate_rolling_mean(benchmark, multi_member_series, size): + """Benchmark `calculate_rolling_mean` across window sizes. + + The implementation wraps a lambda in ``groupby.transform``; a wider window + should not change the cost much, so a strong dependence on ``size`` would + itself be a finding. + """ + result = benchmark(calculate_rolling_mean, multi_member_series.copy(), size) + + assert len(result) == len(multi_member_series) + + +@pytest.mark.parametrize("n_groups", [4, 20, 100]) +def test_bench_calculate_rolling_mean_scaling(benchmark, n_groups): + """Benchmark `calculate_rolling_mean` as the number of groups grows. + + Group count, not row count, is what drives the grouped-transform overhead. + """ + import pandas as pd + + frames = [ + make_series(experiment=f"ssp{i:03d}", ensemble=f"r{i}i1p1f1") + for i in range(n_groups) + ] + data = pd.concat(frames).reset_index(drop=True) + + result = benchmark(calculate_rolling_mean, data.copy(), 9) + + assert len(result) == len(data) + + +@pytest.mark.parametrize("n", [5, 9, 20]) +def test_bench_chunk_ts(benchmark, long_series, n): + """Benchmark `chunk_ts` across chunk sizes.""" + result = benchmark(chunk_ts, long_series.copy(), n) + + assert "chunk" in result.columns + + +def test_bench_get_chunk_info(benchmark, long_series): + """Benchmark `get_chunk_info`. + + Fits a scikit-learn ``LinearRegression`` per chunk inside a Python loop and + grows the result with repeated ``pd.concat``, so it is a prime candidate for + both a closed-form slope and a single terminal concat. + """ + chunked = chunk_ts(long_series.copy(), 9) + + result = benchmark(get_chunk_info, chunked) + + assert len(result) > 0 + + +def test_bench_subset_archive(benchmark, long_series): + """Benchmark `subset_archive`.""" + info = get_chunk_info(chunk_ts(long_series.copy(), 9)) + end_years = sorted(info["end_yr"].unique())[:10] + + result = benchmark(subset_archive, info, end_years) + + assert len(result) <= len(info) diff --git a/benchmarks/bench_recipe.py b/benchmarks/bench_recipe.py new file mode 100644 index 00000000..bad10dad --- /dev/null +++ b/benchmarks/bench_recipe.py @@ -0,0 +1,125 @@ +"""Benchmarks for recipe construction. + +`permute_stitching_recipes` is the most expensive pure-Python routine in the +package. It runs a while loop that repeatedly samples candidate recipes and +rejects those violating the duplicate and collapse constraints, so its cost +depends on how often it has to retry -- which in turn depends on the matching +tolerance and on how many realizations are requested. + +All calls pass an explicit ``seed`` so the amount of work done is identical from +run to run. Without that, retry counts would vary between runs and the timings +would be too noisy to compare. +""" + +import pytest + +from stitches.fx_match import match_neighborhood +from stitches.fx_recipe import ( + get_num_perms, + handle_final_period, + handle_transition_periods, + permute_stitching_recipes, + remove_duplicates, +) + +from .conftest import make_archive, make_chunk_frame + + +@pytest.fixture(scope="module") +def scenario(): + """Return a target/archive pair large enough to be worth timing.""" + target = make_chunk_frame(28) + archive = make_archive(28, n_members=6) + return target, archive + + +@pytest.fixture(scope="module") +def matched(scenario): + """Return matched data for the benchmark scenario.""" + target, archive = scenario + return match_neighborhood(target, archive, tol=0.1) + + +def test_bench_get_num_perms(benchmark, matched): + """Benchmark `get_num_perms`, several chained groupby aggregations.""" + result = benchmark(get_num_perms, matched) + + assert len(result) == 2 + + +def test_bench_remove_duplicates(benchmark, scenario): + """Benchmark `remove_duplicates` on nearest-neighbor matches.""" + target, archive = scenario + nn = match_neighborhood(target, archive, tol=0.0) + + result = benchmark(remove_duplicates, md=nn.copy(), archive=archive) + + assert len(result) > 0 + + +@pytest.mark.parametrize("n_matches", [1, 2, 5]) +def test_bench_permute_stitching_recipes(benchmark, matched, scenario, n_matches): + """Benchmark `permute_stitching_recipes` as more realizations are requested. + + Cost is expected to grow faster than linearly in ``N_matches``, because each + additional realization must avoid every archive point already consumed by the + previous ones, raising the rejection rate. + """ + _, archive = scenario + + result = benchmark( + permute_stitching_recipes, + N_matches=n_matches, + matched_data=matched, + archive=archive, + seed=1, + ) + + assert len(result) > 0 + + +@pytest.mark.parametrize("tol", [0.05, 0.1, 0.3]) +def test_bench_permute_by_tolerance(benchmark, scenario, tol): + """Benchmark `permute_stitching_recipes` across matching tolerances. + + A wider tolerance means more candidates per window: more choice, but also more + matched rows to carry through the constraint checks. + """ + target, archive = scenario + matched_data = match_neighborhood(target, archive, tol=tol) + + result = benchmark( + permute_stitching_recipes, + N_matches=2, + matched_data=matched_data, + archive=archive, + seed=1, + ) + + assert len(result) > 0 + + +def test_bench_handle_transition_periods(benchmark, matched, scenario): + """Benchmark `handle_transition_periods`.""" + _, archive = scenario + messy = permute_stitching_recipes( + N_matches=1, matched_data=matched, archive=archive, seed=1 + ) + + result = benchmark(handle_transition_periods, messy.copy()) + + assert len(result) > 0 + + +def test_bench_handle_final_period(benchmark, matched, scenario): + """Benchmark `handle_final_period`.""" + _, archive = scenario + messy = handle_transition_periods( + permute_stitching_recipes( + N_matches=1, matched_data=matched, archive=archive, seed=1 + ) + ) + + result = benchmark(handle_final_period, messy.copy()) + + assert len(result) > 0 diff --git a/benchmarks/conftest.py b/benchmarks/conftest.py new file mode 100644 index 00000000..d78da5a8 --- /dev/null +++ b/benchmarks/conftest.py @@ -0,0 +1,149 @@ +"""Fixtures and configuration for the performance benchmarks. + +Provides synthetic data at several scales so benchmarks can show how each +function grows with input size, not just how long it takes on one fixed input. +Scaling behavior is what identifies the functions worth optimizing: a routine +whose cost grows quadratically with the number of target windows is a much better +target than one that is merely slow but linear. +""" + +from importlib import resources +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +# Every benchmark in this directory carries the `benchmark` marker. +pytestmark = pytest.mark.benchmark + + +def pytest_configure(config): + """Register the markers this directory uses. + + Declared here as well as in ``pytest.ini`` so that ``pytest benchmarks`` works + when invoked directly from this directory, where the root config may not be + picked up. + """ + for marker in ( + "benchmark: performance measurement, not a correctness assertion", + "slow: long-running benchmark (>30s)", + "network: requires internet access", + "package_data: requires the full Zenodo-minted package data", + ): + config.addinivalue_line("markers", marker) + + +def make_series(n_years=251, model="test_model", experiment="ssp245", ensemble="r1i1p1f1"): + """Build a deterministic synthetic annual temperature series. + + :param n_years: Number of annual values to generate. + :return: A DataFrame with the columns the processing functions require. + :rtype: pandas.DataFrame + """ + years = np.arange(1850, 1850 + n_years) + rng = np.random.default_rng(20240101) + + return pd.DataFrame( + { + "year": years, + "value": 0.00012 * (years - 1850) ** 2 + rng.normal(0, 0.05, size=n_years), + "variable": "tas", + "model": model, + "experiment": experiment, + "ensemble": ensemble, + "unit": "K", + } + ) + + +def make_chunk_frame(n_windows, ensemble="r1i1p1f1", experiment="ssp245", offset=0.0): + """Build a synthetic chunked archive with ``n_windows`` time windows. + + Mirrors the shape produced by ``get_chunk_info``: one row per 9-year window + carrying the level (``fx``) and rate of change (``dx``). + + :param n_windows: Number of windows to generate. + :param offset: Constant added to ``fx``, used to make archive members differ. + :return: A DataFrame shaped like a matching archive. + :rtype: pandas.DataFrame + """ + starts = 1850 + 9 * np.arange(n_windows) + rng = np.random.default_rng(abs(hash((ensemble, experiment))) % (2**32)) + + return pd.DataFrame( + { + "experiment": experiment, + "variable": "tas", + "ensemble": ensemble, + "model": "test_model", + "start_yr": starts, + "end_yr": starts + 8, + "year": starts + 4, + "fx": np.linspace(-1.3, 3.0, n_windows) + offset, + "dx": rng.normal(0.02, 0.01, size=n_windows), + } + ) + + +def make_archive(n_windows, n_members=4): + """Build a synthetic archive spanning several ensemble members. + + :param n_windows: Windows per member. + :param n_members: Number of ensemble members. + :return: A concatenated archive DataFrame. + :rtype: pandas.DataFrame + """ + frames = [ + make_chunk_frame( + n_windows, ensemble=f"r{i + 1}i1p1f1", offset=0.01 * i + ) + for i in range(n_members) + ] + return pd.concat(frames).reset_index(drop=True) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def example_dir(): + """Return the directory of committed example CSVs.""" + return Path(str(resources.files("stitches") / "data" / "example")) + + +@pytest.fixture(scope="session") +def example_target(example_dir): + """Return the committed example target data.""" + return pd.read_csv(example_dir / "test-target_dat.csv") + + +@pytest.fixture(scope="session") +def example_archive(example_dir): + """Return the committed example archive data.""" + return pd.read_csv(example_dir / "test-archive_dat.csv") + + +@pytest.fixture(scope="session") +def example_match_with_duplicates(example_dir): + """Return committed match data that still contains historical duplicates.""" + return pd.read_csv(example_dir / "test-match_w_dup.csv") + + +@pytest.fixture(scope="session") +def long_series(): + """Return a single 251-year series, the full CMIP6 1850-2100 span.""" + return make_series() + + +@pytest.fixture(scope="session") +def multi_member_series(): + """Return a multi-experiment, multi-member series for grouped operations.""" + frames = [ + make_series(experiment=exp, ensemble=ens) + for exp in ("historical", "ssp126", "ssp245", "ssp585") + for ens in (f"r{i}i1p1f1" for i in range(1, 6)) + ] + return pd.concat(frames).reset_index(drop=True) diff --git a/plans/benchmarks-and-regression-testing.md b/plans/benchmarks-and-regression-testing.md index 6fedde76..eec87c0b 100644 --- a/plans/benchmarks-and-regression-testing.md +++ b/plans/benchmarks-and-regression-testing.md @@ -1,5 +1,115 @@ # Benchmarks and Output-Invariance Regression Testing +> **Implementation status.** The harness described here is implemented and green. +> See [§0](#0-implemented-baseline-2026-08-28) for the recorded baseline and the +> scaling findings it produced. + +--- + +## 0. Implemented baseline (2026-08-28) + +Environment: macOS, Python 3.13.3, pandas 2.2.3 → verified also under pandas +3.0.5 / NumPy 2.5.2 / scikit-learn 1.9.0. + +Suite state: + +``` +tests/ 98 passed, 13 skipped (network/package_data), ~8s +tests/regression/ 70 golden-output tests, 34 artifacts, 384K +benchmarks/ 33 benchmarks, ~39s, saved as 0001_baseline.json +``` + +### 0.1 Bugs found by adding the suite + +Writing the regression tests immediately surfaced two crashes that make the +package unusable on a current scientific Python stack. Both are recorded in +[`CHANGELOG.md`](../CHANGELOG.md): + +| Function | Failure | Cause | +|---|---|---| +| `get_chunk_info` | `TypeError: only 0-dimensional arrays can be converted to Python scalars` | `float()` on the one-element array `model.coef_[0]`; removed in NumPy 2 | +| `calculate_rolling_mean` | `ValueError: Cannot specify both 'axis' and 'index'/'columns'` | `drop(columns="value", axis=1)` passes both selectors; rejected by pandas 3 | + +This is the argument for building the suite first: neither bug was visible from +the existing tests. + +### 0.2 Baseline timings (mean, sorted) + +Slowest twelve, which is where optimization effort belongs: + +| Mean | Benchmark | +|---:|---| +| 646.5 ms | `match_neighborhood_scaling[280]` | +| 234.7 ms | `match_neighborhood_scaling[112]` | +| 82.8 ms | `match_neighborhood_archive_width[16]` | +| 78.7 ms | `permute_by_tolerance[0.3]` | +| 67.4 ms | `match_neighborhood_archive_width[8]` | +| 59.5 ms | `match_neighborhood_scaling[28]` | +| 58.1 ms | `match_neighborhood_example[0.1]` | +| 50.6 ms | `permute_by_tolerance[0.1]` | +| 49.3 ms | `permute_stitching_recipes[5]` | +| 49.3 ms | `permute_stitching_recipes[2]` | +| 45.2 ms | `calculate_rolling_mean_scaling[100]` | +| 27.6 ms | `permute_by_tolerance[0.05]` | + +### 0.3 Scaling findings + +These are the conclusions the parametrization was designed to produce. + +**`match_neighborhood` is superlinear in target windows — the top optimization target.** + +| Target windows | Mean | Factor vs. 28 | +|---:|---:|---:| +| 28 | 59.5 ms | 1.0× | +| 112 (4×) | 234.7 ms | 3.9× | +| 280 (10×) | 646.5 ms | **10.9×** | + +Growth is slightly worse than linear (10.9× cost for 10× input). Combined with +its already-dominant absolute cost, this makes the per-group Python loop in +[`stitches/fx_match.py`](../stitches/fx_match.py:150) the clear first target for +vectorization. + +**Archive width costs less than target count.** Going from 2 to 16 ensemble +members (8×) raises cost only 1.6× (53.2 → 82.8 ms), so the bottleneck is +iterating target groups, not scanning the archive. Optimization should attack the +loop, not the search. + +**`calculate_rolling_mean` is dominated by group count, not row count.** + +| Groups | Mean | Per group | +|---:|---:|---:| +| 4 | 2.75 ms | 688 µs | +| 20 | 9.62 ms | 481 µs | +| 100 | 45.2 ms | 452 µs | + +Roughly 450–700 µs of fixed overhead per group, and window size is irrelevant +(3/9/21 all ≈ 9.65 ms at 20 groups). That overhead is the `groupby.transform` +lambda, confirming it as a worthwhile target. + +**`permute_stitching_recipes` saturates in `N_matches` but grows with tolerance.** +N=2 and N=5 are indistinguishable (49.28 vs 49.31 ms) because the synthetic +archive cannot support five collapse-free realizations, so the loop exits early. +Tolerance, by contrast, drives cost steadily (27.6 → 50.6 → 78.7 ms for +0.05/0.1/0.3) as more candidate rows flow through the constraint checks. A +realistic `N_matches` scaling benchmark needs a wider archive. + +**`get_chunk_info` costs 20.1 ms for 28 chunks** — about 700 µs per chunk, spent +constructing a scikit-learn `LinearRegression` per chunk and growing the result +with repeated `pd.concat`. A closed-form slope would remove nearly all of it, and +the golden artifacts make that change safe to attempt. + +### 0.4 Reproducing + +```bash +python -m pytest tests -q # offline correctness, ~8s +python -m pytest tests/regression -q # golden-output invariance +python -m pytest benchmarks --benchmark-only --benchmark-save=baseline +python -m pytest benchmarks --benchmark-only \ + --benchmark-compare=baseline --benchmark-compare-fail=mean:25% +``` + +--- + Purpose: guarantee that modernization work described in [`plans/development-plan.md`](development-plan.md) does **not** alter scientific outputs, and that performance does not silently regress. Two distinct suites: diff --git a/pytest.ini b/pytest.ini index b6fe44c3..ad81673c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,11 @@ [pytest] -# Test discovery +# Test discovery. +# `testpaths` only sets the DEFAULT search path, so a bare `pytest` run collects +# tests/ and skips benchmarks/. Passing an explicit path (`pytest benchmarks`) +# overrides it. `bench_*.py` is listed in python_files so benchmark modules are +# discovered when that path is given. testpaths = tests -python_files = test_*.py +python_files = test_*.py bench_*.py python_classes = Test* python_functions = test_* From ad04bfa93812ea517bca90f9961974598494362e Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:23:10 -0400 Subject: [PATCH 09/14] build: migrate to pyproject.toml and modernize CI Packaging (replaces setup.py and the near-empty setup.cfg): - PEP 621 pyproject.toml with a setuptools backend. - Declares `requests`, which stitches/install_pkgdata.py imports but which was never listed anywhere. A clean install could therefore fail at install_package_data() whenever requests was not pulled in transitively. - Version single-sourced from stitches/_version.py via dynamic=["version"], replacing the build-time regex scrape of that file. - Extras split into test / docs / dev, with pytest-cov, pytest-benchmark, and pyarrow now declared rather than pip-installed ad hoc inside CI steps. pyarrow is required because golden regression artifacts are Parquet. - Drops Python 3.9 (end of life); floor is 3.10, with 3.10-3.13 classifiers. - MANIFEST.in now also ships the tests and golden artifacts so an installed distribution can be verified against recorded outputs, and prunes notebooks, docs, paper, and plans from the sdist. CI: - checkout@v3 -> v4 and setup-python@v4 -> v5. - Matrix widened from 3.9-3.11 to 3.10-3.13, with fail-fast: false so every platform failure is reported instead of only the first. - pip caching, blobless checkout (this repo's history is ~140x its working tree), and concurrency cancellation for superseded runs. - Coverage is now actually uploaded; previously coverage.xml was generated and then thrown away. - New `regression` job running the invariance suite, including a guard that fails the build if any golden artifact was modified during the run. Without that guard an accidental --update-golden would let an output change pass silently, which defeats the point of the suite. - New `benchmark` job, informational only and uploading its JSON as an artifact: shared runners are far too noisy to gate a merge on timings. - New scheduled `integration` job (network + package data, with the Zenodo download cached and keyed on record 8367628) and `latest-deps` job (unpinned resolve, continue-on-error) so upstream breakage surfaces on a schedule rather than in a user's install. This is exactly how the NumPy 2 and pandas 3 crashes fixed earlier in this branch reached a release unnoticed. Verified: package metadata reports requires-python >=3.10 and requests as a runtime dependency, example CSVs remain resolvable through importlib.resources, the workflow YAML parses, and the suite still reports 98 passed / 13 skipped. --- .github/workflows/workflow.yml | 197 ++++++++++++++++++++++++++++++--- CHANGELOG.md | 27 +++++ MANIFEST.in | 24 +++- pyproject.toml | 96 ++++++++++++++++ setup.cfg | 2 - setup.py | 68 ------------ 6 files changed, 330 insertions(+), 84 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index b97518a2..0e5d93e9 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -1,28 +1,199 @@ +--- name: build -on: [push, pull_request] +on: + push: + branches: [main, master, 'release/**'] + pull_request: + schedule: + # Weekly, Monday 06:00 UTC. Catches upstream dependency breakage before a + # user does, which is how the NumPy 2 and pandas 3 failures reached release. + - cron: '0 6 * * 1' + workflow_dispatch: + +# Superseded runs on the same ref are pointless; cancel them. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: - build: - name: Build (${{ matrix.os }}, Python ${{ matrix.python-version }}) + test: + name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) runs-on: ${{ matrix.os }} strategy: + # Report every platform failure, not just the first. + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11"] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + # This repository carries a large history relative to its working + # tree; a blobless checkout avoids transferring it. + filter: blob:none + fetch-depth: 1 + - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + cache: pip + cache-dependency-path: requirements.txt + + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run offline test suite + # No network and no package data: capability-gated tests skip visibly. + run: python -m pytest tests -v --cov=stitches --cov-report=xml --cov-report=term + + - name: Upload coverage + # Previously coverage.xml was generated and then discarded. + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + fail_ci_if_error: false + + regression: + name: Output invariance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Assert outputs are unchanged + run: python -m pytest tests/regression -v -m regression + + - name: Assert golden artifacts were not silently modified + # --update-golden must never run in CI. If the working tree is dirty + # after the suite, something rewrote a golden artifact, which would let + # an output change pass review unnoticed. + run: | + if ! git diff --quiet -- tests/regression/golden; then + echo "::error::Golden artifacts were modified during the test run." + git diff --stat -- tests/regression/golden + exit 1 + fi + + benchmark: + name: Performance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pip install pytest-benchmark + + - name: Run benchmarks + # Informational: shared runners are far too noisy to gate a merge on. + # The saved JSON is what makes trends reviewable over time. + run: | + python -m pytest benchmarks --benchmark-only \ + --benchmark-json=benchmark-results.json \ + --benchmark-columns=mean,stddev,rounds \ + --benchmark-sort=mean + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: benchmark-results-${{ github.sha }} + path: benchmark-results.json + retention-days: 90 + + integration: + name: Network and package data + # Requires the Zenodo download and live Pangeo access, so it is too slow and + # too flaky on third-party availability to run on every push. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements.txt + + - name: Install run: | python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install . - - name: Test and generate coverage report on Linux + python -m pip install -e ".[dev]" + + - name: Cache Zenodo package data + uses: actions/cache@v4 + with: + path: stitches/data + # Keyed on the Zenodo record so a data revision busts the cache. + key: stitches-pkgdata-8367628 + + - name: Run network and package-data tests run: | - pip install pytest - pip install pytest-cov - pytest --cov=./ --cov-report=xml + python -m pytest tests -v --network --slow --package-data + + latest-deps: + name: Latest dependencies + # Resolves dependencies without the repository pins to surface upstream + # breakage early. Allowed to fail so it warns rather than blocks. + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + filter: blob:none + fetch-depth: 1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install with newest resolvable dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade \ + matplotlib xarray numpy pandas intake intake-esm nc_time_axis \ + scikit-learn 'fsspec[gcs]' tqdm requests pytest pyarrow + python -m pip install -e . --no-deps + + - name: Report resolved versions + run: python -m pip list + + - name: Run offline suite against latest dependencies + run: python -m pytest tests -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d37f2c..cbe8fc9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,25 @@ See [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regressi ### Added +- **PEP 621 `pyproject.toml`** replacing `setup.py` and `setup.cfg`. The version + is now single-sourced from `stitches/_version.py` via `dynamic = ["version"]` + rather than parsed with a regex at build time. +- **`requests` declared as a runtime dependency.** It is imported by + `stitches/install_pkgdata.py` but was never declared, so a clean install could + fail at `install_package_data()` if `requests` happened not to be pulled in + transitively. +- **Extras split into `test`, `docs`, and `dev`,** with `pytest-cov`, + `pytest-benchmark`, and `pyarrow` now declared instead of being installed + ad hoc by CI. +- **Performance benchmark suite** (`benchmarks/`, 33 benchmarks) parametrized by + input size to expose scaling rather than single timings. Excluded from the + default `pytest` run; see + [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regression-testing.md). +- **CI jobs for output invariance and benchmarks,** plus a guard that fails the + build if golden artifacts are modified during a test run, and scheduled + `integration` (network + package data) and `latest-deps` (unpinned resolve) + jobs so upstream breakage is caught before release. + - **Golden-output regression suite** (`tests/regression/`) asserting that refactors and dependency upgrades do not change scientific output. Artifacts are stored as Parquet with per-quantity tolerances (exact for indices, years @@ -53,6 +72,14 @@ See [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regressi ### Changed +- **Dropped Python 3.9** (end of life); the floor is now 3.10. The CI matrix + covers 3.10, 3.11, 3.12, and 3.13 on Linux, macOS, and Windows, replacing the + previous 3.9–3.11 matrix. +- **CI modernized:** `actions/checkout@v4` and `actions/setup-python@v5` + (previously v3/v4), pip caching, blobless checkout, concurrency cancellation, + `fail-fast: false` so every platform failure is reported, and coverage actually + uploaded rather than generated and discarded. + - **Tests no longer download package data implicitly.** `tests/conftest.py` previously installed the full Zenodo archive in a session-scoped `autouse` fixture, so every `pytest` invocation downloaded hundreds of megabytes. Package diff --git a/MANIFEST.in b/MANIFEST.in index 6f2407a9..357c75e3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,29 @@ include README.md +include CHANGELOG.md include LICENSE include DISCLAIMER -include notebooks/*.* +include CITATION.cff +include requirements.txt +include pytest.ini + +# Package data. Also declared in pyproject.toml [tool.setuptools.package-data] +# so the wheel carries it; these entries cover the sdist. +include stitches/data/README.md include stitches/data/tas-data/README.md include stitches/data/temp-data/README.md include stitches/data/example/*.csv + +# Ship the tests and the golden regression artifacts so that an installed +# distribution can be verified against the recorded outputs. +recursive-include tests *.py *.csv +recursive-include tests/regression/golden *.parquet *.json +recursive-include benchmarks *.py + +# The notebooks are tutorial material; exclude their large embedded outputs from +# the distribution. See plans/repo-clone-performance.md. +exclude notebooks/*.ipynb +prune notebooks +prune docs +prune paper +prune plans +prune .github diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..730cfd81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,96 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "stitches-emulator" +description = "Amalgamate existing climate data to create monthly climate variable fields" +readme = "README.md" +license = { text = "BSD-2-Clause" } +# 3.9 reached end of life; 3.10 is the floor. See plans/development-plan.md. +requires-python = ">=3.10" +authors = [ + { name = "Abigail Snyder", email = "abigail.snyder@pnnl.gov" }, + { name = "Kalyn Dorheim", email = "kalyn.dorheim@pnnl.gov" }, + { name = "Claudia Tebaldi", email = "claudia.tebaldi@pnnl.gov" }, +] +keywords = ["climate", "emulator", "CMIP6", "downscaling", "temperature"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering :: Atmospheric Science", +] + +dependencies = [ + "matplotlib>=3.3.2", + "xarray>=2022.9.0", + "numpy>=1.23.3", + "pandas>=2.1.0", + "intake>=0.6.6", + "intake-esm>=2021.8.17", + "nc_time_axis>=1.4.1", + "scikit-learn>=1.1", + "fsspec[gcs]>=2022.5.0", + "tqdm>=4.64.1", + # `requests` is imported by stitches/install_pkgdata.py but was never + # declared, so a clean install could fail at install_package_data(). + "requests>=2.28", +] + +# Single source of truth for the version: stitches/_version.py. +dynamic = ["version"] + +[project.optional-dependencies] +test = [ + "pytest>=7.4.3", + "pytest-cov>=4.1", + "pytest-benchmark>=4.0", + # Golden regression artifacts are stored as Parquet. + "pyarrow>=14.0", +] +docs = [ + "nbsphinx>=0.8.6", + "sphinx>=7", + "sphinx_book_theme>=1", + "sphinx-click>=5.1", + "sphinx_copybutton>=0.5", +] +dev = [ + "stitches-emulator[test,docs]", + "build>=1.0", + "setuptools>=64", + "twine>=5.0", + "pre-commit>=3.6.0", +] + +[project.urls] +Homepage = "https://github.com/JGCRI/stitches" +Documentation = "https://jgcri.github.io/stitches/" +Repository = "https://github.com/JGCRI/stitches" +Issues = "https://github.com/JGCRI/stitches/issues" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["stitches*"] + +[tool.setuptools.dynamic] +version = { attr = "stitches._version.__version__" } + +[tool.setuptools.package-data] +stitches = [ + "data/README.md", + "data/example/*.csv", + "data/tas-data/README.md", + "data/temp-data/README.md", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 08aedd7e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description_file = README.md diff --git a/setup.py b/setup.py deleted file mode 100644 index feb8201f..00000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -The setup.py file for the stitches package. - -The stitches package provides tools for stitching together climate model output -into a single, coherent dataset. -""" - - -import re - -from setuptools import find_packages, setup - - -def requirements(): - """ - Read and parse the 'requirements.txt' file. - - Returns a list of requirements specified in the 'requirements.txt' file. - - :return: A list of package requirements. - :rtype: list - """ - with open("requirements.txt") as f: - return f.read().split() - - -version = re.search( - r"__version__ = ['\"]([^'\"]*)['\"]", open("stitches/_version.py").read(), re.M -).group(1) - -setup( - name="stitches-emulator", - version=version, - packages=find_packages(), - url="https://github.com/JGCRI/stitches", - license="BSD 2-Clause", - author="Abigail Snyder, Kalyn Dorheim, Claudia Tebaldi", - author_email="abigail.snyder@pnnl.gov, kalyn.dorheim@pnnl.gov, claudia.tebaldi@pnnl.gov", - description="Amalgamate existing climate data to create monthly climate variable fields", - python_requires=">=3.9.0", - include_package_data=True, - install_requires=requirements(), - extras_require={ - "dev": [ - "pytest>=7.4.3", - "build>=0.5.1", - "nbsphinx>=0.8.6", - "setuptools>=57.0.0", - "sphinx>=7", - "sphinx_book_theme>=1", - "sphinx-click>=5.1", - "sphinx_copybutton>=0.5", - "twine~=3.4.1", - "pre-commit>=3.6.0", - ] - }, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: BSD License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3 :: Only", - "Topic :: Scientific/Engineering :: Atmospheric Science", - ], -) From 05ac65c66e10fb1bdcd22f048993ac2020c0704a Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:29:01 -0400 Subject: [PATCH 10/14] fix: repair make_tas_archive output paths and harden the data installer make_tas_archive could not write its output files at all. Two defects in the same three lines: 1. The path was built as `tas_data_dir + "/" + name + "_tas.csv"`, but importlib.resources.files() returns a Traversable, and Traversable + str raises TypeError. 2. Grouping used groupby(["model"]). With a single-element LIST, pandas 2.0+ yields a one-element tuple as the group name, so even after fixing the path the filenames would have been ('BCC-CSM2-MR',)_tas.csv. Both were verified against the real expressions before fixing. The loop is now write_tas_data_by_model(), extracted so it can be tested without the multi-hour Pangeo download that made this code unreachable in CI -- which is precisely why the bug shipped. It uses os.path.join (also fixing the hardcoded "/" on Windows), groupby("model"), and os.makedirs(exist_ok=True). Audited every other groupby([...]) call site for the same single-key tuple issue. Only fx_recipe.py:532 also passes a one-element list, and it discards the group name, so it is harmless; left alone rather than churn it. install_pkgdata was rewritten for robustness. It previously: - buffered the entire multi-hundred-megabyte archive in memory via BytesIO; - passed no timeout, so a hung server blocked indefinitely; - never called raise_for_status, so a 404 HTML body was written to disk and only failed later with a confusing "not a zip file" error; - imported tqdm but never used it, reporting no progress on a long download; - used os.mkdir, which fails when an intermediate parent is missing; - silently substituted a fallback URL for unregistered versions, so a release without a registered dataset would quietly fetch a mismatched archive; - created temp-data but only re-nested paths containing tas-data, so temp-data members were flattened into the top level and the directory stayed empty; - returned None, giving callers no way to confirm what was written. Now it streams to a temporary file with a tqdm progress bar, sets connect/read timeouts, raises on HTTP errors, creates directories with makedirs(exist_ok=True), warns loudly on an unregistered version, re-nests both known subdirectories, raises an actionable RuntimeError on a corrupt archive, and returns the file list. Logging replaces bare print calls. While testing, extract() was found to depend on the caller having pre-created the subdirectories, failing with an opaque FileNotFoundError from inside shutil otherwise; it now creates its own destination layout. Tests: 29 new (11 for make_tas_archive, 18 for the installer), all offline. The installer tests build a local zip shaped like the real Zenodo archive and stub the HTTP layer, so download semantics, filtering, subdirectory nesting, timeout and error handling are all covered without network access. Suite is now 126 passed / 13 skipped, with golden artifacts unchanged. --- CHANGELOG.md | 29 ++++ stitches/install_pkgdata.py | 228 ++++++++++++++++++------ stitches/make_tas_archive.py | 49 +++++- tests/test_install_data.py | 309 +++++++++++++++++++++++++++++++-- tests/test_make_tas_archive.py | 217 +++++++++++++++++++++++ 5 files changed, 758 insertions(+), 74 deletions(-) create mode 100644 tests/test_make_tas_archive.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe8fc9b..9f5a40cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,35 @@ See [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regressi ### Fixed +- **`make_tas_archive` could not write its output files.** Two defects in the + same three lines: + 1. The path was built as `tas_data_dir + "/" + name + "_tas.csv"`, but + `importlib.resources.files(...)` returns a `Traversable`, so + `Traversable + str` raises `TypeError`. + 2. Grouping used `groupby(["model"])`. With a single-element *list* pandas 2.0+ + yields a one-element tuple as the group name, so even with the path fixed + the filenames would have been `('BCC-CSM2-MR',)_tas.csv`. + + The file-writing loop is now the separately testable + `write_tas_data_by_model`, using `os.path.join` (also correcting the hardcoded + `/` separator on Windows) and `groupby("model")`. This code was previously + unreachable in CI because exercising it requires a multi-hour download of the + full CMIP6 archive, which is why the bug shipped. +- **`install_package_data` hardened.** It buffered the entire multi-hundred- + megabyte archive in memory via `BytesIO`, sent no `timeout` (so a hung server + blocked forever), never called `raise_for_status` (so an HTML error page was + written out and later failed as "not a zip file"), imported `tqdm` without + using it, and used `os.mkdir` (which fails when a parent is missing). It now + streams to a temporary file with a progress bar, sets connect/read timeouts, + raises on HTTP errors, uses `os.makedirs(exist_ok=True)`, reports a clear error + for a corrupt archive, and returns the list of files written instead of `None`. +- **`temp-data` archive members are now extracted to `temp-data`.** Only paths + containing `tas-data` were re-nested, so `temp-data` files were flattened into + the top level even though the directory was created for them. +- **An unregistered package-data version now warns.** Previously the fallback URL + was substituted silently, so a release without a registered dataset would + quietly download a possibly mismatched archive. + - **`get_chunk_info` crashed on NumPy 2.** The per-chunk rate of change was extracted with `float(model.coef_[0])`. Because `LinearRegression` is fitted against a column vector, `coef_` has shape `(1, 1)` and `coef_[0]` is a diff --git a/stitches/install_pkgdata.py b/stitches/install_pkgdata.py index 0fc071cb..62f35abd 100644 --- a/stitches/install_pkgdata.py +++ b/stitches/install_pkgdata.py @@ -1,18 +1,32 @@ -"""This module contains the InstallPackageData class for downloading and unpacking example data from Zenodo.""" +"""Download and unpack the Zenodo-minted package data for stitches. -import importlib +The data archive is large (hundreds of megabytes), so the download is streamed to +a temporary file rather than buffered in memory, and progress is reported through +``tqdm``. +""" + +import logging import os import shutil import tempfile import zipfile from importlib import resources -from io import BytesIO as BytesIO import requests from tqdm import tqdm from ._version import __version__ +logger = logging.getLogger(__name__) + +#: Seconds to wait for the server to respond before giving up. Applies to the +#: connection and to each read, not to the transfer as a whole, so a slow but +#: progressing download is not killed. +DEFAULT_TIMEOUT = (10, 60) + +#: Bytes per streamed chunk. +CHUNK_SIZE = 1024 * 1024 + class InstallPackageData: """ @@ -24,6 +38,8 @@ class InstallPackageData: :param data_dir: Optional. Full path to the directory where you wish to store the data. If not specified, the data will be installed in the data directory of the package. :type data_dir: str + :param timeout: Optional. ``(connect, read)`` timeout in seconds passed to + ``requests``. """ # URL for DOI minted example data hosted on Zenodo @@ -40,78 +56,181 @@ class InstallPackageData: DEFAULT_VERSION = "https://zenodo.org/records/8367628/files/data.zip?download=1" - def __init__(self, data_dir=None): + #: Subdirectories that must exist before extraction. + SUBDIRECTORIES = ("tas-data", "temp-data") + + #: File extensions extracted from the archive. + KEEP_EXTENSIONS = (".csv", ".nc") + + def __init__(self, data_dir=None, timeout=DEFAULT_TIMEOUT): """ Initialize the InstallPackageData class. :param data_dir: The directory where the data will be stored. If None, the data will be installed in the package's data directory. :type data_dir: str, optional + :param timeout: ``(connect, read)`` timeout in seconds. """ self.data_dir = data_dir + self.timeout = timeout - def fetch_zenodo(self): - """Download and unpack the Zenodo minted data for the current stitches distribution.""" - # full path to the stitches root directory where the example dir will be stored - if self.data_dir is None: - data_directory = resources.files("stitches") / "data" - else: - data_directory = self.data_dir - - # build needed subdirectories if they do not already exist - tas_data_path = os.path.join(data_directory, "tas-data") - temp_data_path = os.path.join(data_directory, "temp-data") - if not os.path.exists(tas_data_path): - os.mkdir(tas_data_path) - if not os.path.exists(temp_data_path): - os.mkdir(temp_data_path) + def resolve_url(self, version=None): + """Return the data URL registered for ``version``. - # get the current version of stitches that is installed - current_version = __version__ + :param version: Version to look up; defaults to the installed version. + :return: The download URL. + :rtype: str + """ + version = version or __version__ try: - data_link = InstallPackageData.DATA_VERSION_URLS[current_version] - + return self.DATA_VERSION_URLS[version] except KeyError: - msg = f"Link to data missing for current version: {current_version}." - msg += f" Using default version: {InstallPackageData.DEFAULT_VERSION}" + # Previously this silently substituted DEFAULT_VERSION, so a version + # with no registered dataset would quietly download a possibly + # mismatched archive. Warn loudly instead; the fallback is retained + # so that development versions remain usable. + logger.warning( + "No data archive is registered for stitches version %s. " + "Falling back to %s, which may not match this code. " + "Register the correct URL in InstallPackageData.DATA_VERSION_URLS.", + version, + self.DEFAULT_VERSION, + ) + return self.DEFAULT_VERSION + + def target_directory(self): + """Return the directory the data will be written into. + + :rtype: str + """ + if self.data_dir is None: + return str(resources.files("stitches") / "data") + return str(self.data_dir) - data_link = InstallPackageData.DEFAULT_VERSION + def download(self, url, destination): + """Stream ``url`` to ``destination`` with a progress bar. - print(msg) + The archive is written to disk incrementally rather than accumulated in + memory. The previous implementation held the entire response in a + ``BytesIO``, which needed hundreds of megabytes of RAM and produced no + progress output despite importing ``tqdm``. - # retrieve content from URL - print( - f"Downloading example data for stitches version {current_version}. This may take a few minutes..." - ) - response = requests.get(data_link) + :param url: URL to fetch. + :param destination: Local file path to write to. + :raises requests.HTTPError: If the server returns an error status. + """ + with requests.get(url, stream=True, timeout=self.timeout) as response: + # Without this, an HTML error page would be happily written out and + # then fail later with a confusing "not a zip file" error. + response.raise_for_status() + + total = int(response.headers.get("content-length", 0)) + + with open(destination, "wb") as handle, tqdm( + total=total or None, + unit="B", + unit_scale=True, + unit_divisor=1024, + desc="Downloading stitches data", + ) as progress: + for chunk in response.iter_content(chunk_size=CHUNK_SIZE): + if not chunk: + continue + handle.write(chunk) + progress.update(len(chunk)) + + def extract(self, archive_path, data_directory): + """Extract the wanted members of ``archive_path`` into ``data_directory``. + + Only ``.csv`` and ``.nc`` members are kept, and the archive's directory + nesting is flattened except that ``tas-data`` and ``temp-data`` members + are placed in the matching subdirectory. + + :param archive_path: Path to the downloaded zip file. + :param data_directory: Directory to extract into. + :return: The list of files written. + :rtype: list[str] + """ + written = [] + + # Create the destination layout here rather than relying on the caller + # having done it. `extract` is usable on its own, and a missing + # subdirectory would otherwise surface as an opaque FileNotFoundError + # from deep inside shutil.copy. + os.makedirs(data_directory, exist_ok=True) + for subdirectory in self.SUBDIRECTORIES: + os.makedirs(os.path.join(data_directory, subdirectory), exist_ok=True) + + with zipfile.ZipFile(archive_path) as zipped: + members = [ + name + for name in zipped.namelist() + if os.path.splitext(name)[-1] in self.KEEP_EXTENSIONS + ] + + for name in tqdm(members, desc="Extracting", unit="file"): + basename = os.path.basename(name) + + # Preserve the subdirectory layout the package expects. The + # original code handled tas-data only, so temp-data members were + # flattened into the top level even though the directory was + # created for them. + for subdirectory in self.SUBDIRECTORIES: + if subdirectory in name: + basename = os.path.join(subdirectory, basename) + break + + out_file = os.path.join(data_directory, basename) + + with tempfile.TemporaryDirectory() as tdir: + zipped.extract(name, tdir) + shutil.copy(os.path.join(tdir, name), out_file) + + written.append(out_file) + logger.debug("Unzipped: %s", out_file) + + return written - with zipfile.ZipFile(BytesIO(response.content)) as zipped: - # extract each file in the zipped dir to the project - for f in zipped.namelist(): - extension = os.path.splitext(f)[-1] + def fetch_zenodo(self): + """Download and unpack the Zenodo minted data for the current stitches distribution. - # Extract only the csv and nc files - if all([len(extension) > 0, extension in (".csv", ".nc")]): - basename = os.path.basename(f) + :return: The list of files written. + :rtype: list[str] + """ + data_directory = self.target_directory() + + # `os.makedirs(exist_ok=True)` rather than `os.mkdir`, which fails when an + # intermediate parent is missing and races against concurrent callers. + for subdirectory in self.SUBDIRECTORIES: + os.makedirs(os.path.join(data_directory, subdirectory), exist_ok=True) + + data_link = self.resolve_url() + + logger.info( + "Downloading example data for stitches version %s. This may take a few minutes.", + __version__, + ) - # Check to see if tas-data is in the file path - if "tas-data" in f: - basename = os.path.join("tas-data", basename) + # Download to a temporary file so a failed or interrupted transfer cannot + # leave a truncated archive behind to be mistaken for a good one. + with tempfile.TemporaryDirectory() as tdir: + archive_path = os.path.join(tdir, "data.zip") - out_file = os.path.join(data_directory, basename) + self.download(data_link, archive_path) - # extract to a temporary directory to be able to only keep the file out of the dir structure - with tempfile.TemporaryDirectory() as tdir: - # extract file to temporary directory - zipped.extract(f, tdir) + try: + written = self.extract(archive_path, data_directory) + except zipfile.BadZipFile as exc: + raise RuntimeError( + f"The file downloaded from {data_link} is not a valid zip archive. " + "The download may have been truncated, or the URL may no longer " + "point at the data archive." + ) from exc - # construct temporary file full path with name - tfile = os.path.join(tdir, f) + logger.info("Wrote %d files to %s", len(written), data_directory) - print(f"Unzipped: {out_file}") - # transfer only the file sans the parent directory to the data package - shutil.copy(tfile, out_file) + return written def install_package_data(data_dir: str = None): @@ -125,8 +244,9 @@ def install_package_data(data_dir: str = None): Default is the data directory of the package. :type data_dir: str - :return: None + :return: The list of files written. + :rtype: list[str] """ zen = InstallPackageData(data_dir=data_dir) - zen.fetch_zenodo() + return zen.fetch_zenodo() diff --git a/stitches/make_tas_archive.py b/stitches/make_tas_archive.py index 6fade3be..f8da120d 100644 --- a/stitches/make_tas_archive.py +++ b/stitches/make_tas_archive.py @@ -11,6 +11,45 @@ import stitches.fx_util as util +def write_tas_data_by_model(data_frame, output_dir): + """Write one CSV of global tas values per model. + + Split out of `make_tas_archive` so the filename construction can be tested + without running the multi-hour archive build. + + Two defects were fixed here: + + 1. The path was built as ``output_dir + "/" + name + "_tas.csv"``. Because + `importlib.resources.files` returns a ``Traversable`` (a ``PosixPath`` in + practice), ``Traversable + str`` raises ``TypeError``. ``os.path.join`` on + ``str(output_dir)`` is used instead, which also fixes the Windows + separator. + 2. Grouping used ``groupby(["model"])``. With a *list* of keys, pandas 2.0+ + yields a one-element ``tuple`` as the group name, so the filenames would + have become ``('BCC-CSM2-MR',)_tas.csv``. Grouping by the scalar key + ``"model"`` yields the plain string the filename expects. + + :param data_frame: Global tas data containing a ``model`` column. + :type data_frame: pandas.DataFrame + :param output_dir: Directory to write the per-model CSV files into. Created + if it does not already exist. + :return: The list of paths written, ordered by model name. + :rtype: list[str] + """ + util.check_columns(data_frame, {"model"}) + + directory = str(output_dir) + os.makedirs(directory, exist_ok=True) + + files = [] + for name, group in data_frame.groupby("model"): + path = os.path.join(directory, f"{name}_tas.csv") + files.append(path) + group.to_csv(path, index=False) + + return files + + def join_exclude(dat, drop): """Drop some rows from a data frame. @@ -445,15 +484,9 @@ def make_tas_archive(anomaly_startYr=1995, anomaly_endYr=2014): data["zstore"] = new_zstore # Save a copy of the tas values, these are the value that will be used to get the - # tas data chunks. Note that this file has to be compressed so will need to read in - # using pickle_utils.load() - - files = [] + # tas data chunks. tas_data_dir = resources.files("stitches") / "data" / "tas-data" - for name, group in data.groupby(["model"]): - path = tas_data_dir + "/" + name + "_tas.csv" - files.append(path) - group.to_csv(path, index=False) + files = write_tas_data_by_model(data, tas_data_dir) print("Global tas data complete") diff --git a/tests/test_install_data.py b/tests/test_install_data.py index 36ecacf5..6d3458fd 100644 --- a/tests/test_install_data.py +++ b/tests/test_install_data.py @@ -1,22 +1,307 @@ -import unittest +"""Tests for the Zenodo package-data installer. + +The download itself needs the network, but the parts most likely to break -- +URL resolution, directory creation, archive extraction, and error handling on a +bad response -- are exercised offline against a locally built zip file. +""" + +import io +import os +import zipfile + +import pytest +import requests import stitches import stitches.install_pkgdata as sd -class TestInstallRawData(unittest.TestCase): - """Tests for verifying the installation of raw data.""" +@pytest.fixture +def fake_archive(tmp_path): + """Build a zip file shaped like the real Zenodo data archive. + + Includes nested ``tas-data`` and ``temp-data`` members plus files that must be + filtered out, so extraction behavior can be checked in full. + """ + path = tmp_path / "data.zip" + + with zipfile.ZipFile(path, "w") as zipped: + zipped.writestr("data/matching_archive.csv", "a,b\n1,2\n") + zipped.writestr("data/pangeo_table.csv", "a,b\n3,4\n") + zipped.writestr("data/tas-data/CanESM5_tas.csv", "year,value\n1850,1.0\n") + zipped.writestr("data/tas-data/BCC-CSM2-MR_tas.csv", "year,value\n1850,2.0\n") + zipped.writestr("data/temp-data/scratch.csv", "x\n1\n") + zipped.writestr("data/example.nc", b"\x89HDF\r\n\x1a\n") + # Members that must be ignored. + zipped.writestr("data/README.md", "# not extracted\n") + zipped.writestr("data/notes.txt", "not extracted\n") + + return path + + +# --------------------------------------------------------------------------- +# URL resolution +# --------------------------------------------------------------------------- + + +def test_current_version_has_registered_data_url(): + """The installed version has an entry in the URL registry. + + Guards against tagging a release without registering its dataset, which would + silently downgrade users to the fallback archive. + """ + assert stitches.__version__ in sd.InstallPackageData.DATA_VERSION_URLS + + +def test_resolve_url_returns_registered_url(): + """A known version resolves to its registered URL.""" + installer = sd.InstallPackageData() + + resolved = installer.resolve_url("0.13") + + assert resolved == sd.InstallPackageData.DATA_VERSION_URLS["0.13"] + + +def test_resolve_url_warns_for_unknown_version(caplog): + """An unregistered version falls back but logs a warning. + + The original code printed a message built into a local variable that was + discarded on some paths, so the mismatch could pass unnoticed. + """ + installer = sd.InstallPackageData() + + with caplog.at_level("WARNING"): + resolved = installer.resolve_url("99.99.99-does-not-exist") + + assert resolved == sd.InstallPackageData.DEFAULT_VERSION + assert any("no data archive is registered" in r.message.lower() for r in caplog.records) + + +def test_default_version_is_a_string(): + """``DEFAULT_VERSION`` is a URL string.""" + assert isinstance(sd.InstallPackageData.DEFAULT_VERSION, str) + assert sd.InstallPackageData.DEFAULT_VERSION.startswith("https://") + + +# --------------------------------------------------------------------------- +# Target directory +# --------------------------------------------------------------------------- + + +def test_target_directory_honors_explicit_dir(tmp_path): + """An explicit ``data_dir`` is used verbatim.""" + installer = sd.InstallPackageData(data_dir=str(tmp_path)) + + assert installer.target_directory() == str(tmp_path) + + +def test_target_directory_defaults_into_package(): + """With no ``data_dir``, the package's own data directory is used.""" + installer = sd.InstallPackageData() + + assert installer.target_directory().endswith(os.path.join("stitches", "data")) + + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + + +def test_extract_keeps_only_csv_and_nc(tmp_path, fake_archive): + """Only ``.csv`` and ``.nc`` members are extracted.""" + target = tmp_path / "out" + + installer = sd.InstallPackageData(data_dir=str(target)) + written = installer.extract(str(fake_archive), str(target)) + + extensions = {os.path.splitext(f)[-1] for f in written} + assert extensions <= {".csv", ".nc"} + assert not (target / "README.md").exists() + assert not (target / "notes.txt").exists() + + +def test_extract_preserves_tas_data_subdirectory(tmp_path, fake_archive): + """``tas-data`` members land in the ``tas-data`` subdirectory.""" + target = tmp_path / "out" + + installer = sd.InstallPackageData(data_dir=str(target)) + installer.extract(str(fake_archive), str(target)) + + assert (target / "tas-data" / "CanESM5_tas.csv").is_file() + assert (target / "tas-data" / "BCC-CSM2-MR_tas.csv").is_file() + + +def test_extract_preserves_temp_data_subdirectory(tmp_path, fake_archive): + """``temp-data`` members land in the ``temp-data`` subdirectory. + + Regression test: the original code created ``temp-data`` but only re-nested + members whose path contained ``tas-data``, so ``temp-data`` files were + flattened into the top level and the directory stayed empty. + """ + target = tmp_path / "out" + + installer = sd.InstallPackageData(data_dir=str(target)) + installer.extract(str(fake_archive), str(target)) + + assert (target / "temp-data" / "scratch.csv").is_file() + assert not (target / "scratch.csv").exists() + + +def test_extract_flattens_top_level_members(tmp_path, fake_archive): + """Members outside a known subdirectory are written to the top level.""" + target = tmp_path / "out" + + installer = sd.InstallPackageData(data_dir=str(target)) + installer.extract(str(fake_archive), str(target)) + + assert (target / "matching_archive.csv").is_file() + assert (target / "pangeo_table.csv").is_file() + assert (target / "example.nc").is_file() + + +def test_extract_content_is_intact(tmp_path, fake_archive): + """Extracted file contents match what was archived.""" + target = tmp_path / "out" + + installer = sd.InstallPackageData(data_dir=str(target)) + installer.extract(str(fake_archive), str(target)) + + assert (target / "matching_archive.csv").read_text() == "a,b\n1,2\n" + + +# --------------------------------------------------------------------------- +# Download error handling +# --------------------------------------------------------------------------- + + +class _FakeResponse: + """Minimal stand-in for a streaming ``requests`` response.""" + + def __init__(self, payload=b"", status=200, headers=None): + self._payload = payload + self.status_code = status + self.headers = headers or {"content-length": str(len(payload))} + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"{self.status_code} error") + + def iter_content(self, chunk_size=1): + stream = io.BytesIO(self._payload) + while True: + chunk = stream.read(chunk_size) + if not chunk: + return + yield chunk + + +def test_download_raises_on_http_error(tmp_path, monkeypatch): + """An error status raises instead of writing an error page to disk. + + The original code never called ``raise_for_status``, so a 404 HTML body was + written out and only failed later with a confusing "not a zip file" error. + """ + monkeypatch.setattr( + sd.requests, "get", lambda *a, **k: _FakeResponse(b"404", status=404) + ) + + installer = sd.InstallPackageData(data_dir=str(tmp_path)) + + with pytest.raises(requests.HTTPError): + installer.download("https://example.invalid/data.zip", str(tmp_path / "d.zip")) + + +def test_download_streams_payload_to_disk(tmp_path, monkeypatch): + """The response body is written to the destination path.""" + payload = b"x" * 4096 + monkeypatch.setattr(sd.requests, "get", lambda *a, **k: _FakeResponse(payload)) + + destination = tmp_path / "d.zip" + installer = sd.InstallPackageData(data_dir=str(tmp_path)) + installer.download("https://example.invalid/data.zip", str(destination)) + + assert destination.read_bytes() == payload + + +def test_download_passes_timeout(tmp_path, monkeypatch): + """A timeout is supplied, so a hung server cannot block forever. + + The original call had no timeout at all. + """ + seen = {} + + def _capture(url, **kwargs): + seen.update(kwargs) + return _FakeResponse(b"data") + + monkeypatch.setattr(sd.requests, "get", _capture) + + installer = sd.InstallPackageData(data_dir=str(tmp_path)) + installer.download("https://example.invalid/data.zip", str(tmp_path / "d.zip")) + + assert seen.get("timeout") is not None + assert seen.get("stream") is True + + +def test_fetch_zenodo_reports_bad_archive(tmp_path, monkeypatch): + """A non-zip download produces an actionable error message.""" + monkeypatch.setattr( + sd.requests, "get", lambda *a, **k: _FakeResponse(b"not a zip file at all") + ) + + installer = sd.InstallPackageData(data_dir=str(tmp_path)) + + with pytest.raises(RuntimeError, match="not a valid zip archive"): + installer.fetch_zenodo() + + +def test_fetch_zenodo_creates_subdirectories(tmp_path, monkeypatch, fake_archive): + """Required subdirectories are created, including missing parents. + + The original code used ``os.mkdir``, which raises if a parent is absent. + """ + payload = fake_archive.read_bytes() + monkeypatch.setattr(sd.requests, "get", lambda *a, **k: _FakeResponse(payload)) + + target = tmp_path / "missing" / "parents" / "data" + installer = sd.InstallPackageData(data_dir=str(target)) + + installer.fetch_zenodo() + + assert (target / "tas-data").is_dir() + assert (target / "temp-data").is_dir() + assert (target / "tas-data" / "CanESM5_tas.csv").is_file() + + +def test_fetch_zenodo_is_idempotent(tmp_path, monkeypatch, fake_archive): + """Running twice succeeds; existing directories are not an error.""" + payload = fake_archive.read_bytes() + monkeypatch.setattr(sd.requests, "get", lambda *a, **k: _FakeResponse(payload)) + + target = tmp_path / "data" + installer = sd.InstallPackageData(data_dir=str(target)) + + installer.fetch_zenodo() + written = installer.fetch_zenodo() + + assert len(written) > 0 - def test_instantiate(self): - """Test instantiation of InstallPackageData with a fake data directory.""" - zen = sd.InstallPackageData(data_dir="fake") - # Ensure default version is set - self.assertEqual(str, type(zen.DEFAULT_VERSION)) +def test_install_package_data_returns_written_files(tmp_path, monkeypatch, fake_archive): + """The module-level helper returns the list of files written. - # Ensure URLs are present for current version - self.assertTrue(stitches.__version__ in zen.DATA_VERSION_URLS) + Previously it returned ``None``, giving callers no way to confirm what landed. + """ + payload = fake_archive.read_bytes() + monkeypatch.setattr(sd.requests, "get", lambda *a, **k: _FakeResponse(payload)) + written = sd.install_package_data(data_dir=str(tmp_path / "data")) -if __name__ == "__main__": - unittest.main() + assert isinstance(written, list) + assert len(written) == 6 diff --git a/tests/test_make_tas_archive.py b/tests/test_make_tas_archive.py new file mode 100644 index 00000000..b15faabd --- /dev/null +++ b/tests/test_make_tas_archive.py @@ -0,0 +1,217 @@ +"""Tests for the tas archive construction helpers. + +Focused on `write_tas_data_by_model`, which was extracted from `make_tas_archive` +specifically so its filename construction could be tested. The full +`make_tas_archive` run downloads the entire CMIP6 tas archive from Pangeo and +takes hours, so the bug these tests cover was previously unreachable in CI -- +which is why it shipped. +""" + +import os + +import pandas as pd +import pytest + +from stitches.make_tas_archive import ( + calculate_anomaly, + join_exclude, + rbind, + write_tas_data_by_model, +) + + +@pytest.fixture +def tas_frame(): + """Return a small multi-model global tas frame.""" + rows = [] + for model in ("BCC-CSM2-MR", "CanESM5", "UKESM1-0-LL"): + for year in range(2000, 2005): + rows.append( + { + "model": model, + "experiment": "historical", + "ensemble": "r1i1p1f1", + "variable": "tas", + "year": year, + "value": 287.0 + year / 1000, + "unit": "K", + } + ) + return pd.DataFrame(rows) + + +# --------------------------------------------------------------------------- +# write_tas_data_by_model +# --------------------------------------------------------------------------- + + +def test_write_tas_data_by_model_filenames(tmp_path, tas_frame): + """Filenames are ``_tas.csv`` with no tuple punctuation. + + Regression test for two defects present in the original implementation: + + * ``groupby(["model"])`` with a single-element *list* yields a one-element + tuple as the group name under pandas 2.0+, which would have produced + ``('BCC-CSM2-MR',)_tas.csv``. + * the path was built with ``Traversable + str``, which raises ``TypeError``. + """ + files = write_tas_data_by_model(tas_frame, tmp_path) + + names = sorted(os.path.basename(f) for f in files) + assert names == [ + "BCC-CSM2-MR_tas.csv", + "CanESM5_tas.csv", + "UKESM1-0-LL_tas.csv", + ] + + for name in names: + assert "(" not in name and ")" not in name and "," not in name + + +def test_write_tas_data_by_model_accepts_path_like(tas_frame, tmp_path): + """A path-like output directory is accepted, not just a ``str``. + + ``make_tas_archive`` passes ``resources.files("stitches") / "data" / + "tas-data"``, which is a ``Traversable`` (a ``PosixPath`` in practice). The + original code concatenated it with ``+``, which raises ``TypeError`` for path + objects. Passing a ``Path`` here reproduces that call shape. + """ + files = write_tas_data_by_model(tas_frame, tmp_path) + + assert len(files) == 3 + assert all(isinstance(f, str) for f in files), "paths should be returned as str" + + +def test_write_tas_data_by_model_matches_production_directory_type(tas_frame, tmp_path): + """The type produced by ``importlib.resources`` is handled. + + Guards the specific failure mode directly: the object type that + ``make_tas_archive`` supplies must not raise when joined. + """ + from importlib import resources + + production_type = type(resources.files("stitches") / "data" / "tas-data") + + # Confirm the assumption behind this test still holds, then exercise the + # helper with an instance of that same type pointing at a disposable dir. + assert issubclass(production_type, os.PathLike) + + files = write_tas_data_by_model(tas_frame, production_type(tmp_path)) + + assert len(files) == 3 + + +def test_write_tas_data_by_model_files_exist_and_roundtrip(tmp_path, tas_frame): + """Each written file is readable and holds only its own model's rows.""" + files = write_tas_data_by_model(tas_frame, tmp_path) + + total = 0 + for path in files: + assert os.path.isfile(path), f"{path} was not written" + + frame = pd.read_csv(path) + assert frame["model"].nunique() == 1 + + expected_model = os.path.basename(path).replace("_tas.csv", "") + assert frame["model"].unique()[0] == expected_model + + total += len(frame) + + assert total == len(tas_frame), "rows were lost or duplicated across files" + + +def test_write_tas_data_by_model_creates_missing_directory(tmp_path, tas_frame): + """A nonexistent output directory is created rather than raising. + + The original code called ``os.mkdir``, which fails if an intermediate parent + is missing; ``os.makedirs(exist_ok=True)`` is used instead. + """ + target = tmp_path / "deeply" / "nested" / "tas-data" + + files = write_tas_data_by_model(tas_frame, target) + + assert target.is_dir() + assert len(files) == 3 + + +def test_write_tas_data_by_model_is_idempotent(tmp_path, tas_frame): + """Writing twice into the same directory succeeds and does not duplicate rows.""" + write_tas_data_by_model(tas_frame, tmp_path) + files = write_tas_data_by_model(tas_frame, tmp_path) + + for path in files: + frame = pd.read_csv(path) + assert len(frame) == 5 + + +def test_write_tas_data_by_model_requires_model_column(tmp_path): + """A frame without a ``model`` column is rejected.""" + with pytest.raises(Exception): + write_tas_data_by_model(pd.DataFrame({"value": [1, 2]}), tmp_path) + + +def test_write_tas_data_by_model_handles_model_names_with_dots(tmp_path): + """Model identifiers containing dots and dashes survive intact. + + Real CMIP6 source IDs include names such as ``EC-Earth3-Veg-LR`` and + ``FGOALS-f3-L``; the filename must not be truncated at a separator. + """ + frame = pd.DataFrame( + {"model": ["EC-Earth3-Veg-LR", "FGOALS-f3-L"], "value": [1.0, 2.0]} + ) + + files = write_tas_data_by_model(frame, tmp_path) + names = sorted(os.path.basename(f) for f in files) + + assert names == ["EC-Earth3-Veg-LR_tas.csv", "FGOALS-f3-L_tas.csv"] + + +# --------------------------------------------------------------------------- +# Supporting helpers +# --------------------------------------------------------------------------- + + +def test_rbind_with_empty_frame(): + """`rbind` combines frames even when one side is empty.""" + populated = pd.DataFrame({"a": [1, 2]}) + empty = pd.DataFrame({"a": []}) + + assert len(rbind(populated, empty)) == 2 + assert len(rbind(empty, populated)) == 2 + + +def test_join_exclude_drops_matching_rows(): + """`join_exclude` removes the rows described by the drop frame.""" + data = pd.DataFrame({"model": ["a", "b", "c"], "value": [1, 2, 3]}) + drop = pd.DataFrame({"model": ["b"]}) + + out = join_exclude(data, drop) + + assert sorted(out["model"]) == ["a", "c"] + + +def test_calculate_anomaly_centers_reference_period(): + """`calculate_anomaly` subtracts the mean of the reference window. + + Constructed so the reference-period mean is exactly known: with a constant + value across the reference years, every anomaly in that window must be zero. + """ + rows = [] + for year in range(1995, 2021): + rows.append( + { + "model": "test_model", + "experiment": "historical" if year <= 2014 else "ssp245", + "ensemble": "r1i1p1f1", + "variable": "tas", + "year": year, + "value": 287.0, + "unit": "K", + } + ) + data = pd.DataFrame(rows) + + out = calculate_anomaly(data, startYr=1995, endYr=2014) + + reference = out[(out["year"] >= 1995) & (out["year"] <= 2014)] + assert reference["value"].abs().max() == pytest.approx(0.0, abs=1e-12) From 33999a4c2d2eaf83696970c5fcaeb3859650ac45 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:31:30 -0400 Subject: [PATCH 11/14] build: add nbstripout and large-file guard to pre-commit Preventive half of the clone-size fix. History is 288 MB against a ~2 MB working tree, and committed notebook outputs are the largest recurring contributor: notebooks/stitches-quickstart.ipynb alone accumulated 38.5 MB across 31 revisions. Base64-encoded PNGs sit on single enormous JSON lines that git cannot delta-compress, and re-running a notebook rewrites every image byte-for-byte, so each commit stores a full new copy. Measured: the four notebooks in the working tree total 7010 KB and would be 122 KB with outputs stripped, a 98% reduction. That 7 MB is what gets re-stored on every notebook commit today. Changes: - nbstripout hook, so outputs never enter history again. - check-added-large-files (--maxkb=512). The committed golden regression artifacts are unaffected; the largest is 16 KB. - check-yaml, check-toml, and check-merge-conflict, the last of which matters now that pyproject.toml is the build configuration. - pyupgrade/black/nbqa targets moved from py39 to py310 to match the new floor. Accepted trade-off: stripped notebooks show no figures in GitHub's static renderer. The executed versions are published through the nbsphinx docs build. Also corrected two overstated recommendations in the clone-performance plan after checking them against the tree: - docs/source/getting-started/output_*.png (1.65 MB) cannot simply be deleted; they are referenced by six .. image:: directives in quickstarter.rst. Removing them requires converting that page to an nbsphinx-executed notebook, so it is now tracked as a docs-restructuring task instead of a quick deletion. - stitches_diagram.jpg is confirmed byte-identical in all three locations, but the paper/ copy backs the JOSS submission and builds independently via draft-pdf.yml. Recommendation narrowed to deduplicating only the docs/ and notebooks/ copies; ~400 KB is not worth risking the published paper build. This commit deliberately does not strip the existing notebooks. Doing so is a large content change that alters what readers see on GitHub, so it belongs in its own reviewed commit; the hook prevents any further growth in the meantime. --- .pre-commit-config.yaml | 33 ++++++++++++++++++++++++----- plans/repo-clone-performance.md | 37 ++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b600402..de66ad16 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,13 +9,36 @@ repos: rev: v3.8.0 hooks: - id: pyupgrade - args: ['--py39-plus'] + args: ['--py310-plus'] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements + - id: check-merge-conflict + - id: check-yaml + - id: check-toml + # Repository history is ~140x the size of the working tree because large + # generated artifacts were committed and later deleted. This blocks the + # next one. See plans/repo-clone-performance.md. + # Golden regression artifacts are excluded: they are intentionally + # committed, small, and individually well under this limit. + - id: check-added-large-files + args: ['--maxkb=512'] + + # Jupyter notebooks are the single largest recurring contributor to repository + # growth: stitches-quickstart.ipynb alone accumulated 38.5 MB across 31 + # revisions because base64-encoded PNG outputs are committed. Re-running a + # notebook rewrites every image byte-for-byte on one enormous JSON line, which + # git cannot delta-compress, so each commit stores a full new copy. + # + # Stripping outputs means GitHub's static renderer shows no figures; the + # executed notebooks are published through the nbsphinx docs build instead. + - repo: https://github.com/kynan/nbstripout + rev: 0.7.1 + hooks: + - id: nbstripout - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: @@ -25,7 +48,7 @@ repos: rev: 23.3.0 hooks: - id: black - args: ['--target-version=py39'] + args: ['--target-version=py310'] - repo: https://github.com/pycqa/flake8 rev: 6.0.0 hooks: @@ -40,11 +63,11 @@ repos: rev: 1.7.0 hooks: - id: nbqa-pyupgrade - args: ['--py39-plus'] + args: ['--py310-plus'] additional_dependencies: ['pyupgrade==3.8.0'] - id: nbqa-black additional_dependencies: ['black==23.3.0'] - args: ['--line-length=88', '--target-version=py39'] + args: ['--line-length=88', '--target-version=py310'] - id: nbqa-isort additional_dependencies: ['isort==5.12.0'] args: ['--profile=black'] @@ -57,7 +80,7 @@ repos: hooks: - id: blackdoc additional_dependencies: ['black==23.3.0'] - args: ['--target-version=py39'] + args: ['--target-version=py310'] - repo: https://github.com/adrienverge/yamllint.git rev: v1.32.0 hooks: diff --git a/plans/repo-clone-performance.md b/plans/repo-clone-performance.md index d74cc3fd..4b46cf49 100644 --- a/plans/repo-clone-performance.md +++ b/plans/repo-clone-performance.md @@ -204,12 +204,43 @@ This mitigates the symptom immediately without touching history. args: ['--maxkb=512'] ``` -3. **Stop committing generated docs images.** Delete `docs/source/getting-started/output_*.png` and let `nbsphinx` execute the notebook during the docs build (`nbsphinx_execute = "always"` with cached data), or generate them into `docs/_build/`. - -4. **Deduplicate `stitches_diagram.jpg`** to one canonical location (e.g. `docs/source/images/`) and reference it from the notebook and paper. +3. **Stop committing generated docs images.** `docs/source/getting-started/output_*.png` is 1.65 MB across six files. + + Verified caveat: they are **currently referenced** by six `.. image::` + directives in + [`docs/source/getting-started/quickstarter.rst`](../docs/source/getting-started/quickstarter.rst), + so deleting them breaks the docs build. Removing them properly means + converting `quickstarter.rst` into an `nbsphinx`-executed notebook + (`nbsphinx_execute = "always"`, with the package data cached) so the figures + are generated at build time. That is a docs-restructuring job, tracked under + WS-8, not a drive-by deletion. + +4. **Deduplicate `stitches_diagram.jpg`.** Confirmed byte-identical in all three + locations (md5 `fba15a18445d1bba43eee5bdff95d51e`), roughly 200 KB each: + `docs/source/getting-started/`, `notebooks/figs/`, `paper/`. + + Caveat: the `paper/` copy belongs to the JOSS submission, which builds + separately via [`draft-pdf.yml`](../.github/workflows/draft-pdf.yml) and should + stay reproducible as published. Deduplicate the `docs/` and `notebooks/` + copies and leave `paper/` alone. At ~400 KB of potential saving this is minor + next to §3.4, and not worth risking the paper build. Trade-off to accept: stripped notebooks show no output on GitHub's static renderer. Mitigate by publishing executed notebooks in the hosted docs (already wired via `nbsphinx`) and linking to them from the README. +Measured effect of stripping outputs from the four notebooks currently in the +working tree: + +| Notebook | Now | Stripped | +|---|---:|---:| +| `stitches-quickstart.ipynb` | 2694.6 KB | 29.0 KB | +| `stitches_takehome_GCAMAnnualMeeting2023.ipynb` | 2428.2 KB | 47.7 KB | +| `stitches_training_GCAMAnnualMeeting2023.ipynb` | 1372.1 KB | 29.0 KB | +| `preparing-input-data.ipynb` | 515.1 KB | 16.8 KB | +| **Total** | **7010.1 KB** | **122.5 KB (98% smaller)** | + +That 7 MB is re-stored in full on every commit that touches a notebook, which is +how a ~1 MB file accumulated 38.5 MB of history across 31 revisions. + ### 3.3 Structural options for the notebooks | Option | Clone cost | GitHub preview | Effort | From 37559266b2988e9856a2a30d1af1cb14832c2a23 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:37:38 -0400 Subject: [PATCH 12/14] docs: document the testing and benchmark workflow; fix regression marker Marker fix (the important part): `pytest -m regression` was deselecting all 70 regression tests. The marker was being attached from an autouse fixture, and later from this package's own pytest_collection_modifyitems, but `-m` filtering is evaluated by pytest's own collection hook and does not reliably observe markers added from another plugin's hook. This mattered because the CI regression job selects with `-m regression`, so the entire output-invariance suite would have reported green while running nothing -- the same class of silent-pass failure this branch removed from test_stitch.py and test_pangeo.py. Replaced with a module-level `pytestmark` in each regression module, where it cannot be missed, and left a comment in conftest.py explaining why the hook approach was abandoned so nobody reintroduces it. Verified all three selections now behave: pytest tests/regression -m regression -> 70 passed pytest tests -m "not regression" -> 56 passed, 70 deselected pytest tests -> 126 passed, 13 skipped CONTRIBUTING.md gains sections on the development environment (blobless clone, editable install with the dev extra, pre-commit), the tiered test suite and its capability flags, and the benchmark workflow. The output-invariance rules are stated explicitly: assume a regression failure is your own bug, regenerate goldens only for a genuine defect, and any golden change requires a CHANGELOG entry naming the defect plus domain-maintainer review. README.md updates the Python floor from 3.9 to 3.10, states the tested matrix, documents the blobless clone for contributors, summarizes the test commands, and links the three planning documents. --- CONTRIBUTING.md | 83 ++++++++++++++++++- README.md | 47 ++++++++++- tests/regression/conftest.py | 15 ++-- tests/regression/test_invariance_match.py | 4 + .../regression/test_invariance_processing.py | 4 + tests/regression/test_invariance_recipe.py | 4 + tests/regression/test_invariance_util.py | 4 + 7 files changed, 149 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 995eb7f8..f234e32d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,85 @@ We welcome third-party patches, which are essential for advancing the science and architecture of STITCHES. But there are a few guidelines that we ask contributors to follow, guidelines that ease the maintainers' organizational and logistical duties, while encouraging development by others. All contributors agree to abide by the code of conduct. +## Development Environment + +Because this repository has a large history relative to its working tree, prefer a +blobless clone; it fetches file contents on demand and is substantially faster: + +```bash +git clone --filter=blob:none https://github.com/JGCRI/stitches.git +cd stitches + +python -m venv .venv && source .venv/bin/activate # Python >= 3.10 +python -m pip install -e ".[dev]" +pre-commit install +``` + +## Testing + +The suite is organized in tiers so the common case is fast and offline. + +```bash +pytest # offline correctness, a few seconds +pytest tests/regression # golden-output invariance +pytest --network # additionally hit Pangeo +pytest --package-data # additionally download the Zenodo archive +pytest --network --slow --package-data # everything +``` + +Capability-gated tests are declared with markers and **skip visibly** when the +capability is not enabled; they never pass vacuously. Each flag has an environment +variable equivalent (`STITCHES_TEST_NETWORK`, `STITCHES_TEST_SLOW`, +`STITCHES_TEST_PACKAGE_DATA`) for CI use. + +### Output invariance: the most important rule + +`stitches` produces scientific data, so **changing its output changes published +results**. `tests/regression/` compares the current code against golden artifacts +recorded from a known-good baseline. Any refactor, dependency bump, or +optimization must leave these unchanged. + +If a regression test fails, first assume you have introduced a bug. Only if the +recorded output is genuinely *wrong* should you regenerate: + +```bash +pytest tests/regression --update-golden +``` + +A pull request that modifies anything under `tests/regression/golden/` **must**: + +1. add a `CHANGELOG.md` entry under `### Fixed` or `### Changed — outputs` + naming the defect the new output corrects, and +2. be reviewed by a domain maintainer, not only a code reviewer. + +CI fails the build if golden artifacts change during a test run, so an accidental +`--update-golden` cannot slip through. + +When adding a function that produces data, add an invariance test for it. + +## Benchmarks + +Performance is tracked separately from correctness, and is excluded from the +default `pytest` run: + +```bash +pytest benchmarks --benchmark-only --benchmark-save=baseline +pytest benchmarks --benchmark-only --benchmark-compare=baseline +``` + +Run benchmarks on a single fixed OS and Python version; numbers from different +machines, or from different CI matrix jobs, are not comparable. Optimization work +is only acceptable when the benchmarks improve **and** the invariance suite still +passes. Current baseline measurements and the scaling analysis derived from them +are in [`plans/benchmarks-and-regression-testing.md`](plans/benchmarks-and-regression-testing.md). + +## Notebooks + +Notebook outputs are stripped automatically on commit by `nbstripout`. This is +deliberate: committed base64 image outputs were the largest single source of +repository growth. Do not re-add them, and do not commit generated data files. +See [`plans/repo-clone-performance.md`](plans/repo-clone-performance.md). + ## Getting Started * Make sure you have a [GitHub account](https://github.com/signup/free). @@ -20,8 +99,10 @@ But there are a few guidelines that we ask contributors to follow, guidelines th * We will never accept pull requests to the `master` branch. * Check for unnecessary whitespace with `git diff --check` before committing. * Make sure your commit messages are descriptive but succinct, describing what was changed and why, and **reference the relevant issue number**. Make commits of logical units. -* Make sure you have added the necessary tests for your changes. Tests should be included in the root `tests` directory and are facilitated using `pytest` which is installed with the development version of STITCHES. See more info on using `pytest` here: https://docs.pytest.org/en/7.4.x/contents.html +* Make sure you have added the necessary tests for your changes. Tests belong in the root `tests` directory and run under `pytest`, which is installed with the development extra. See the [`pytest` documentation](https://docs.pytest.org/). +* If your change touches code that produces data, add or update an invariance test in `tests/regression/` (see **Output invariance** above). * Run _all_ the tests to assure nothing else was accidentally broken. +* Record notable changes in `CHANGELOG.md`. Anything that alters output **must** be recorded there. ## Submitting Changes diff --git a/README.md b/README.md index e98d9a7e..c0db5541 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ Amalgamate existing climate data to create monthly climate variable fields. Jupyter notebooks hosted on `stitches` use functionality that is contained within the accompanying Python package. > **NOTE** -> Ensure you are using Python >= 3.9. Calling `python` may use a different instance. Some users may need to use `python3` or the like instead. +> Ensure you are using Python >= 3.10. Calling `python` may use a different instance. Some users may need to use `python3` or the like instead. +`stitches` is tested on Python 3.10–3.13 across Linux, macOS, and Windows. ### Installation To install for use, run the following: @@ -42,6 +43,46 @@ but note that this will take several hours to run. | `stitches-quickstart.ipynb` | Simple tutorial to demonstrate how `stitches` can be used as an emulator. | ### Contributing -`stitches` users and developers must agree to our community guidelines outlines in our community guidelines outlines in our +`stitches` users and developers must agree to the community guidelines set out in our [Contributor Guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md). -Open an issue to ask for help or report an issue ([how to open a GitHub issue](https://docs.github.com/en/enterprise-server@3.1/issues/tracking-your-work-with-issues/creating-an-issue)). +Open an issue to ask for help or report a problem ([how to open a GitHub issue](https://docs.github.com/en/enterprise-server@3.1/issues/tracking-your-work-with-issues/creating-an-issue)). + +#### Cloning for development + +This repository's history is large relative to its working tree, so a full clone +transfers far more than you need. Use a blobless clone, which fetches file +contents on demand: + +```bash +git clone --filter=blob:none https://github.com/JGCRI/stitches.git +``` + +Then install the development extra and enable the hooks: + +```bash +python -m pip install -e ".[dev]" +pre-commit install +``` + +#### Testing + +```bash +pytest # offline suite, runs in seconds +pytest tests/regression # assert scientific outputs are unchanged +pytest --network --slow --package-data # full suite, downloads data +``` + +`stitches` produces scientific data, so output-changing modifications are held to +a higher bar: `tests/regression/` pins outputs against recorded golden artifacts, +and any intentional change must be justified in [`CHANGELOG.md`](CHANGELOG.md). +See [Contributor Guidelines](CONTRIBUTING.md) for the full workflow. + +### Development plans + +Ongoing modernization work is tracked in [`plans/`](plans/): + +| Document | Purpose | +|---|---| +| [`development-plan.md`](plans/development-plan.md) | Current-state assessment, known defects, and prioritized workstreams | +| [`benchmarks-and-regression-testing.md`](plans/benchmarks-and-regression-testing.md) | Output-invariance harness, benchmark baseline, and scaling analysis | +| [`repo-clone-performance.md`](plans/repo-clone-performance.md) | Why clones are slow, with measurements and remediation options | diff --git a/tests/regression/conftest.py b/tests/regression/conftest.py index bbefacef..d2cba91c 100644 --- a/tests/regression/conftest.py +++ b/tests/regression/conftest.py @@ -258,11 +258,10 @@ def golden(request): ) -@pytest.fixture(autouse=True) -def _mark_regression(request): - """Apply the ``regression`` marker to everything in this package. - - Keeps ``pytest -m regression`` accurate without requiring each module to - repeat ``pytestmark``. - """ - request.node.add_marker(pytest.mark.regression) +# Note: the `regression` marker is applied declaratively via a module-level +# `pytestmark` in each test module rather than from a hook or autouse fixture +# here. `-m` filtering is applied by pytest's own collection hook, and a marker +# added from another `pytest_collection_modifyitems` is not reliably visible to +# it, which made `pytest -m regression` deselect the whole suite. Since the CI +# job selects on this marker, a silent full-suite skip is the worst possible +# failure mode, so the marker is declared where it cannot be missed. diff --git a/tests/regression/test_invariance_match.py b/tests/regression/test_invariance_match.py index 8063c96f..7c06d8a2 100644 --- a/tests/regression/test_invariance_match.py +++ b/tests/regression/test_invariance_match.py @@ -15,6 +15,10 @@ shuffle_function, ) +# Every test in this module is an output-invariance check; the CI regression job +# selects on this marker. +pytestmark = pytest.mark.regression + @pytest.fixture(scope="module") def target(read_example): diff --git a/tests/regression/test_invariance_processing.py b/tests/regression/test_invariance_processing.py index 1a1fbf14..aba6d30f 100644 --- a/tests/regression/test_invariance_processing.py +++ b/tests/regression/test_invariance_processing.py @@ -22,6 +22,10 @@ subset_archive, ) +# Every test in this module is an output-invariance check; the CI regression job +# selects on this marker. +pytestmark = pytest.mark.regression + def _series(start=1850, end=2100, model="test_model", experiment="ssp245", ensemble="r1i1p1f1"): """Build a deterministic synthetic annual temperature series. diff --git a/tests/regression/test_invariance_recipe.py b/tests/regression/test_invariance_recipe.py index 78c1113b..23757d3a 100644 --- a/tests/regression/test_invariance_recipe.py +++ b/tests/regression/test_invariance_recipe.py @@ -28,6 +28,10 @@ # so the regression suite and the unit suite describe the same scenario. from tests.test_fx_recipe import TestRecipe +# Every test in this module is an output-invariance check; the CI regression job +# selects on this marker. +pytestmark = pytest.mark.regression + TARGET_DATA = TestRecipe.TARGET_DATA ARCHIVE_DATA = TestRecipe.ARCHIVE_DATA diff --git a/tests/regression/test_invariance_util.py b/tests/regression/test_invariance_util.py index 37a6f103..3971bd07 100644 --- a/tests/regression/test_invariance_util.py +++ b/tests/regression/test_invariance_util.py @@ -16,6 +16,10 @@ from stitches.fx_data import get_lat_name, global_mean from stitches.fx_util import anti_join, combine_df, nrow, selstr +# Every test in this module is an output-invariance check; the CI regression job +# selects on this marker. +pytestmark = pytest.mark.regression + # --------------------------------------------------------------------------- # fx_util From 34895e3ced473c617edd89e8a4b65eb7748ea908 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:38:34 -0400 Subject: [PATCH 13/14] docs(plans): record delivered work and deferral rationale Adds a progress section to the development plan marking WS-1 through WS-4 as delivered and WS-7/WS-8 as partial, with a table of the six defects fixed. Worth recording explicitly: three of those defects were not in the original assessment. The NumPy 2 crash in get_chunk_info, the pandas 3 crash in calculate_rolling_mean, and the pytest -m regression marker bug were all found only after the regression suite and CI wiring existed. That is the concrete justification for the plan's sequencing rule that the safety net lands first. Also documents why four items were deliberately left undone rather than half-finished: hot-path optimization now has benchmarks and goldens to make it verifiable but should be its own change; the history rewrite is worth roughly an order of magnitude but rewrites every SHA and needs maintainer sign-off plus care around the Zenodo/JOSS citation trail; the committed docs renders are still referenced by quickstarter.rst; and stripping 7 MB from existing notebooks changes what readers see on GitHub and belongs in a separate reviewed commit. --- plans/development-plan.md | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/plans/development-plan.md b/plans/development-plan.md index 16e14453..37ae1298 100644 --- a/plans/development-plan.md +++ b/plans/development-plan.md @@ -1,5 +1,64 @@ # `stitches` Development Plan +Status: WS-1 through WS-4 substantially delivered; see §0 + +--- + +## 0. Progress (2026-08-28, branch `release/v1`) + +| Workstream | Status | +|---|---| +| WS-1 Baseline & regression safety net | **Done** — 70 golden-output tests, 34 artifacts, 33 benchmarks with a saved baseline | +| WS-2 Packaging modernization | **Done** — PEP 621 `pyproject.toml`, `requests` declared, version single-sourced | +| WS-3 Python & dependency matrix | **Done** — 3.9 dropped, CI covers 3.10–3.13 on three OSes, scheduled unpinned resolve | +| WS-4 Correctness fixes | **Done** — all four confirmed defects fixed, each with tests | +| WS-5 Code quality & structure | Not started (ruff consolidation, type hints, `fx_recipe` split) | +| WS-6 Performance | Not started; benchmarks and scaling analysis now in place to guide it | +| WS-7 Repository size | Partial — prevention landed; history rewrite awaits maintainer sign-off | +| WS-8 Docs & community | Partial — `CHANGELOG.md`, CONTRIBUTING, README done; quickstart restructure outstanding | + +Test suite: **126 passed, 13 skipped** offline in ~8s. Previously a bare `pytest` +downloaded hundreds of megabytes before it could run at all. + +### Defects found and fixed + +Three of the five were **not** in the original assessment; they surfaced only once +the regression suite existed, which is the argument for building it first. + +| Defect | Effect | Found by | +|---|---|---| +| `get_chunk_info` used `float()` on a 1-element array | `TypeError` on NumPy 2; broke all archive generation and matching | New regression tests | +| `calculate_rolling_mean` passed `columns=` and `axis=` together | `ValueError` on pandas 3 | New regression tests | +| `make_tas_archive` built paths with `Traversable + str` and `groupby([key])` | Could not write output at all; filenames would be `('MODEL',)_tas.csv` | Original assessment | +| `requests` imported but never declared | Clean installs could fail at `install_package_data()` | Original assessment | +| `install_pkgdata` buffered whole archive, no timeout, no `raise_for_status`, `temp-data` never populated | Memory pressure, indefinite hangs, misleading errors | Audit during the fix | +| `pytest -m regression` selected nothing | Would have reported the CI regression job green while running zero tests | Verifying the CI wiring | + +The last one is worth noting: the marker bug was itself an instance of the +silent-pass failure mode this work set out to eliminate. + +### Deferred, with reasons + +- **Hot-path optimization (WS-6).** Deliberately not attempted yet. The + benchmarks now identify the targets precisely — `match_neighborhood` is + superlinear in target windows and dominates total cost — and the golden + artifacts make the work verifiable. Doing it before that existed would have + been unverifiable. +- **Git history rewrite (WS-7 §3.4).** Measured as worth roughly an order of + magnitude, but it rewrites every commit SHA and needs a coordinated freeze, + fork re-clones, and care around the Zenodo/JOSS citation trail. Requires + maintainer sign-off, not a unilateral decision. +- **Deleting committed docs renders.** They are referenced by six `.. image::` + directives in `quickstarter.rst`; removing them requires converting that page + to an `nbsphinx`-executed notebook. +- **Stripping outputs from existing notebooks.** The hook prevents further + growth; rewriting 7 MB of existing notebook content changes what readers see on + GitHub and belongs in its own reviewed commit. + +--- + +## 1. Original Assessment + Status: draft for review Scope: modernization, correctness, performance, repository hygiene, and regression protection for the `stitches-emulator` package (current version `0.13`). From 71799c46b1a3eb3355a3b66d43f0b83ac4952ba4 Mon Sep 17 00:00:00 2001 From: crvernon Date: Fri, 28 Aug 2026 16:43:15 -0400 Subject: [PATCH 14/14] remove joss paper workflow --- .github/workflows/draft-pdf.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/draft-pdf.yml diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml deleted file mode 100644 index 76310246..00000000 --- a/.github/workflows/draft-pdf.yml +++ /dev/null @@ -1,23 +0,0 @@ -on: [push] - -jobs: - paper: - runs-on: ubuntu-latest - name: Paper Draft - steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Build draft PDF - uses: openjournals/openjournals-draft-action@master - with: - journal: joss - # This should be the path to the paper within your repo. - paper-path: paper/paper.md - - name: Upload - uses: actions/upload-artifact@v1 - with: - name: paper - # This is the output path where Pandoc will write the compiled - # PDF. Note, this should be the same directory as the input - # paper.md - path: paper/paper.pdf