Skip to content

Commit 78f2b32

Browse files
ci: after fixtures subset of tests added to run via ci
1 parent 37d632f commit 78f2b32

13 files changed

Lines changed: 249 additions & 80 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint-and-test:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 10
13+
14+
steps:
15+
- uses: actions/checkout@v6
16+
17+
- name: Install uv and Python
18+
uses: astral-sh/setup-uv@v8.1.0
19+
with:
20+
python-version: "3.12"
21+
enable-cache: true
22+
23+
- name: Install dependencies
24+
run: uv sync --dev
25+
26+
- name: Ruff
27+
run: uv run ruff check src/ tests/
28+
29+
- name: Mypy
30+
run: uv run mypy src/
31+
32+
- name: Pytest representative subset
33+
run: |
34+
uv run pytest -q \
35+
tests/test_smoke.py \
36+
tests/test_api.py \
37+
tests/test_io.py \
38+
tests/test_validation.py \
39+
tests/test_sorting.py \
40+
tests/test_blocking.py \
41+
tests/test_unique_removal.py \
42+
tests/test_splitting.py \
43+
tests/test_combat.py \
44+
tests/test_limma.py \
45+
tests/test_pipeline.py \
46+
tests/test_cli.py \
47+
tests/test_benchmarks.py

.github/workflows/ci.yml.disabled

Lines changed: 0 additions & 33 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44

55
All notable changes to this project 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).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Fixture-backed CI workflow with a representative test subset for push and pull request runs.
12+
13+
### Changed
14+
15+
- R-generated TSV and CSV fixtures are now tracked in git so CI can run concordance coverage without regenerating them.
16+
- Package metadata, smoke checks, and parity docs are aligned on version 0.3.1.
17+
18+
### Fixed
19+
20+
- Repo-wide Ruff and mypy failures cleared on the current CI target.
21+
722
## [0.3.1] - 2026-05-28
823

924
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<p align="center">
1111
<img src="https://img.shields.io/badge/python-3.12--3.14-2D7D46?style=flat-square&logo=python&logoColor=white" alt="Python 3.12-3.14">
12-
<img src="https://img.shields.io/badge/version-0.3.0-8B5CF6?style=flat-square" alt="v0.3.0">
12+
<img src="https://img.shields.io/badge/version-0.3.1-8B5CF6?style=flat-square" alt="v0.3.1">
1313
<img src="https://img.shields.io/badge/status-alpha-C17D10?style=flat-square" alt="Alpha">
1414
<!-- <img src="https://github.com/LangeLab/HarmonizePy/actions/workflows/ci.yml/badge.svg" alt="CI"> -->
1515
<!-- <img src="https://img.shields.io/badge/576%20tests-passing-22C55E?style=flat-square" alt="576 tests"> -->
@@ -145,7 +145,7 @@ harmonizepy data.tsv batch.csv --config run.toml
145145
# Dry-run: validates inputs and prints the run plan, exits without computing
146146
harmonizepy data.tsv batch.csv --dry-run
147147
# Prints:
148-
# HarmonizePy 0.3.0 dry run
148+
# HarmonizePy 0.3.1 dry run
149149
# ────────────────────────────────────────────────────
150150
# Features: 1500
151151
# Samples: 45

assets/FEATURE_PARITY.md

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1-
# Feature Parity: R HarmonizR v1.10.0 vs HarmonizePy v0.3.0
1+
# Feature Parity: R HarmonizR v1.10.0 vs HarmonizePy v0.3.1
22

33
References: R source at `ref/HarmonizR/R/` (13 files). R sva v3.60.0, limma, HarmonizR v1.10.0 via Bioconductor 3.23.
44

55
---
66

7-
## WARNING: Fixture Limitations Remain
7+
## Current Validation Boundaries
88

9-
185 existing concordance tests use synthetic data with clean structural missingness only. Per-cell NaN testing is now covered by 5 new `percell_nan_*` fixtures and verified against the murine_medulloblastoma real dataset. However:
9+
The parity surface is broader than the original structural-missingness fixture suite. In addition to the dense and structural-missingness fixtures, the repository now includes dedicated checks for per-cell NaN handling, medium `jaccard` and `seriation` sort+block cases, chain-rescue `unique_removal` toggles, and the combined stress case (`sort + block + ur + per-cell NaN`).
1010

11-
- Jaccard/Seriation sort only verified on one 3-batch fixture each
12-
- Unique-removal chain-rescue scenario not tested
13-
- Combined stress (sort+block+ur+per-cell NaN) not tested
11+
Remaining limits are narrower:
12+
13+
- Jaccard and seriation are fixture-backed and unit-tested, but exact algorithm matching to R is intentionally out of scope.
14+
- Combined-stress validation is now fixture-backed, but exact NaN-position parity on every feature is still not used as a release gate because of the documented mixed-NaN edge case.
15+
- Python source coverage is now measured directly in-repo and is currently 94% for `src/harmonizepy`.
1416

1517
---
1618

@@ -104,7 +106,7 @@ R uses `plyr::rbind.fill` (align by row name). Python uses pre-allocated array +
104106

105107
## 4. Unique-Removal Algorithm: MINOR DIVERGENCE
106108

107-
R updates affiliation list in-place enabling chain rescues. Python pre-computes non-unique set from original list; rescued singletons don't become new targets. Only verified on highmiss dataset. Chain-rescue scenario not tested.
109+
R updates affiliation list in-place enabling chain rescues. Python pre-computes the non-unique target set from the original list, so rescued singletons do not become new targets in the same pass. This difference is now covered by dedicated `chain_rescue` fixtures for both `ur=True` and `ur=False`.
108110

109111
---
110112

@@ -133,19 +135,19 @@ Python retains more features in all cases. The trade-off: retained features are
133135

134136
## 7. Verification Status
135137

136-
| Scenario | ComBat 1-4 | limma | Notes |
137-
| -------------------------------- | ------------------------- | ------------------------- | ---------------------------------------------- |
138-
| Dense synthetic | PASS (rtol 2e-5 to 5e-4) | PASS (rtol 1e-9) | 185 fixture tests |
139-
| Structural missingness | PASS (rtol 5e-4) | PASS (rtol 1e-8) | 185 fixture tests |
140-
| Blocking (block=2,4) | PASS (rtol 5e-4) | PASS (rtol 1e-8) | 185 fixture tests |
141-
| Sparsity sort + block | PASS (rtol 5e-4) | PASS (rtol 1e-8) | 185 fixture tests |
142-
| Jaccard sort + block | PASS (rtol 5e-4) | -- | One 3-batch fixture |
143-
| Seriation sort + block | PASS (rtol 5e-4) | -- | One 3-batch fixture |
144-
| Per-cell NaN synthetic | PASS (max_rel 0.0000) | PASS (max_rel 0.0000) | NaN positions match |
145-
| **Murine unblocked** | **PASS (max_rel 0.0003)** | **PASS (max_rel 2e-10)** | **Real data, NaN positions match** |
146-
| **Murine blocked (block=2, direct recheck)** | **PASS (ComBat mode 1: max_rel 6e-06)** | **--** | **Current benchmark summaries used an invalid R wrapper. Full blocked summary must be regenerated.** |
147-
| Unique-removal chain rescue | NOT TESTED | -- | No fixture |
148-
| Combined stress | NOT TESTED | NOT TESTED | No fixture |
138+
| Scenario | ComBat 1-4 | limma | Notes |
139+
| --- | --- | --- | --- |
140+
| Dense synthetic | PASS (rtol 2e-5 to 5e-4) | PASS (rtol 1e-9) | baseline fixture suite |
141+
| Structural missingness | PASS (rtol 5e-4) | PASS (rtol 1e-8) | baseline fixture suite |
142+
| Blocking (block=2,4) | PASS (rtol 5e-4) | PASS (rtol 1e-8) | fixture-backed |
143+
| Sparsity sort + block | PASS (rtol 5e-4) | PASS (rtol 1e-8) | fixture-backed |
144+
| Jaccard sort + block | PASS (rtol 5e-4) | -- | medium block=2 fixture plus broad unit coverage |
145+
| Seriation sort + block | PASS (rtol 5e-4) | -- | medium block=2 fixture plus broad unit coverage |
146+
| Per-cell NaN synthetic | PASS (max_rel 0.0000) | PASS (max_rel 0.0000) | NaN positions match |
147+
| Murine unblocked | PASS (max_rel 0.0003) | PASS (max_rel 2e-10) | real data, NaN positions match |
148+
| Murine blocked (block=2) | PASS (ComBat mode 1: max_rel 6e-06) | -- | benchmark wrapper bug fixed; blocked summary should still be regenerated |
149+
| Unique-removal chain rescue | PASS (rtol 1e-4) | -- | dedicated `ur=True` and `ur=False` fixtures |
150+
| Combined stress | PASS on value concordance for NaN-matching features | -- | dedicated fixture; minority NaN-position mismatch allowed |
149151

150152
The catastrophic blocked benchmark rows previously reported in markdown and JSON artifacts were not real algorithmic divergences. The benchmark R wrapper passed `block` as an integer, HarmonizR rejected it, and the R side silently ran unblocked while Python still ran blocked. A corrected direct rerun on murine `ComBat` mode 1 with `block=2` gives `max_rel 5.73e-06`, `p95_rel 5.48e-15`, and `nan_match 1.0`. Treat existing benchmark-summary rows with `Block = 2` as invalid until they are regenerated with the fixed wrapper.
151153

pyproject.toml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "harmonizepy"
3-
version = "0.3.0"
3+
version = "0.3.1"
44
description = "Pure-Python batch-effect harmonization (ComBat + limma), validated against R sva/HarmonizR."
55
readme = "README.md"
66
license = "GPL-3.0-only"
@@ -49,6 +49,7 @@ Issues = "https://github.com/LangeLab/HarmonizePy/issues"
4949
[dependency-groups]
5050
dev = [
5151
"pytest>=8.0",
52+
"pytest-cov>=5.0",
5253
"mypy>=1.9",
5354
"ruff>=0.4",
5455
"pandas-stubs>=2.0",
@@ -61,16 +62,18 @@ build-backend = "hatchling.build"
6162
[tool.pytest.ini_options]
6263
testpaths = ["tests"]
6364

65+
[tool.coverage.run]
66+
source = ["src/harmonizepy"]
67+
68+
[tool.coverage.report]
69+
show_missing = true
70+
skip_covered = true
71+
6472
[tool.mypy]
6573
strict = true
6674
python_version = "3.12"
6775
ignore_missing_imports = false
6876

69-
[[tool.mypy.overrides]]
70-
module = "tests.*"
71-
disallow_untyped_defs = false
72-
disallow_untyped_calls = false
73-
7477
[[tool.mypy.overrides]]
7578
module = "pyarrow"
7679
ignore_missing_imports = true

src/harmonizepy/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@
3434
# harmonizepy.affiliation.remove_unique_combinations
3535
# ---------------------------------------------------------------------------
3636

37-
__version__ = "0.3.0"
37+
__version__ = "0.3.1"

src/harmonizepy/combat.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
import logging
3333
from dataclasses import dataclass
34-
from typing import Any
34+
from typing import Any, cast
3535

3636
import numpy as np
3737
import numpy.typing as npt
@@ -255,7 +255,7 @@ def _int_eprior(
255255

256256
d = np.maximum(d_hat, 1e-12)
257257
not_nan = ~np.isnan(s_data)
258-
n_per_gene = np.float64(not_nan.sum(axis=1))
258+
n_per_gene = not_nan.sum(axis=1).astype(np.float64)
259259
sum_x = np.nansum(s_data, axis=1)
260260
sum_x2 = np.nansum(s_data * s_data, axis=1)
261261
g_hat_sq = g_hat * g_hat
@@ -618,25 +618,25 @@ def _combat_nan(
618618
grouped_design_layouts = _prepare_grouped_batch_design_layouts(design, valid_row_groups)
619619

620620
# ---- Per-feature B.hat (Beta.NA) ---------------------------------------
621-
B_hat = _beta_na_grouped_batch_design(
621+
b_hat = _beta_na_grouped_batch_design(
622622
data,
623623
design,
624624
valid_row_groups,
625625
grouped_design_layouts,
626-
) # noqa: N806
626+
)
627627

628628
# ---- Grand mean and pooled variance (per-feature, NaN-safe) ------------
629629
# NOTE: R sva::ComBat uses DIFFERENT formulas:
630630
# No NaN: mean(residuals^2)
631631
# Has NaN: rowVars(residuals, na.rm=TRUE) (sample variance, ddof=1)
632632
if ref_idx is not None:
633-
grand_mean = B_hat[ref_idx]
633+
grand_mean = b_hat[ref_idx]
634634
ref_cols = batches_ind[ref_idx]
635-
fitted_ref = (design[:, ref_cols].T @ B_hat).T
635+
fitted_ref = (design[:, ref_cols].T @ b_hat).T
636636
var_n = _row_var_nan_grouped(data[:, ref_cols] - fitted_ref)
637637
else:
638-
grand_mean = (batch_sizes / n_samples) @ B_hat
639-
fitted = (design.T @ B_hat).T
638+
grand_mean = (batch_sizes / n_samples) @ b_hat
639+
fitted = (design.T @ b_hat).T
640640
var_n = _row_var_nan_grouped(data - fitted, valid_row_groups)
641641

642642
var_pooled = np.maximum(var_n, 1e-12)
@@ -714,7 +714,7 @@ def _combat_nan(
714714
if ref_idx is not None:
715715
corrected[:, batches_ind[ref_idx]] = data[:, batches_ind[ref_idx]]
716716

717-
return corrected
717+
return cast(_Array, corrected)
718718

719719

720720
def _combat_dense(

tests/test_benchmarks.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,8 @@ def test_run_all_allows_feature_normalization(self, monkeypatch, caplog) -> None
323323
"benchmarks.harness.resolve_dataset_paths",
324324
lambda *_args, **_kwargs: DatasetPaths(
325325
name="tiny",
326-
input_path=Path("."),
327-
desc_path=Path("."),
326+
input_path=Path(),
327+
desc_path=Path(),
328328
features=3,
329329
samples=4,
330330
batches=2,
@@ -405,8 +405,8 @@ def fake_load_dataset(paths):
405405

406406
paths = DatasetPaths(
407407
name="tiny",
408-
input_path=Path("."),
409-
desc_path=Path("."),
408+
input_path=Path(),
409+
desc_path=Path(),
410410
features=2,
411411
samples=4,
412412
batches=2,
@@ -417,4 +417,4 @@ def fake_load_dataset(paths):
417417
data_df, desc_df = BenchmarkHarness._load_dataset(paths)
418418

419419
assert data_df is expected_data
420-
assert desc_df is expected_desc
420+
assert desc_df is expected_desc

tests/test_combat.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@
1818
import pandas as pd
1919
import pytest
2020

21-
from harmonizepy.combat import _beta_na_grouped_batch_design, _group_valid_rows, _int_eprior, _it_sol, _row_var_nan_grouped, combat
21+
from harmonizepy.combat import (
22+
_beta_na_grouped_batch_design,
23+
_group_valid_rows,
24+
_int_eprior,
25+
_it_sol,
26+
_row_var_nan_grouped,
27+
combat,
28+
)
2229
from harmonizepy.combat_wrapper import adjust_combat
2330

2431
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)