Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
# import anything Julia-touching at module import time, so this runs clean without the
# [vle] extra. Julia-backed VLE + regression tests that need it stay a separate, gated
# job for whenever CI grows a Julia-capable runner.
#
# Coverage is measured on the 3.12 matrix leg only (one upload, not four -- Codecov
# would otherwise report the same numbers repeatedly) and uploaded to Codecov, which
# works without a token for public repos (see README.md's badge). `pytest-cov`'s own
# `fail_under` (pyproject.toml's [tool.coverage.report]) is what actually gates
# coverage regressions -- Codecov's upload is reporting/visibility on top of that, not
# a second gate, so a transient upload failure (e.g. codecov.io being briefly
# unreachable) doesn't fail CI (`fail_ci_if_error: false`).
name: CI

on:
Expand Down Expand Up @@ -46,4 +54,14 @@ jobs:
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run the default test suite (unit + integration + regression, not vle)
if: matrix.python-version != '3.12'
run: pytest -q
- name: Run the default test suite with coverage (3.12 only, for Codecov)
if: matrix.python-version == '3.12'
run: pytest -q --cov=bits_for_gaps --cov-report=xml --cov-report=term-missing
- name: Upload coverage to Codecov
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v5
with:
files: ./coverage.xml
fail_ci_if_error: false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist/
htmlcov/
.coverage
.coverage.*
coverage.xml

# Local environments
.venv/
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ entry below summarizes the library relative to the paper's original research cod
`numpy<2` in every release, and no NumPy 1.x publishes a Python 3.13 wheel -- an
upstream constraint, not a limitation of this package's own pins. See
`docs/installation.md`.
- Measured, reported, and floor-gated code coverage for `src/bits_for_gaps` (the
shipped package only -- `examples/`, `paper/`, and `tests/` are repo-only and
excluded from the denominator). `pyproject.toml`'s `[tool.coverage.run]`/
`[tool.coverage.report]` configure branch coverage, honest exclusions
(`pragma: no cover`, `TYPE_CHECKING`, `NotImplementedError`, `__repr__`, the
`__main__` guard), and a `fail_under = 99` floor. `pytest -q` alone is unaffected
(fast, no coverage overhead); measure explicitly with `pytest --cov=bits_for_gaps
--cov-report=term-missing` (documented in `README.md`/`docs/installation.md`). CI
uploads coverage from the Python 3.12 matrix leg to Codecov (works without a token
for this public repo); see the new Codecov badge in `README.md`. Closed all 18
statements missed on the pre-existing 97% baseline -- 15 with real tests (the PEP
562 lazy-import path, a grid-trimming branch, `entropy.cholesky`'s correctness, two
unused-elsewhere `adaptiveEntropy` wrapper methods, the `showLMLres` diagnostic
branch, the deprecated `run_model` entry point, and a checkpoint-writing branch for
non-2-D runs) and 3 with a `# pragma: no cover` plus a documented reason (one
mathematically-unreachable guard, already noted in the test suite; one
`@tf.function`-wrapped line that genuinely executes but is invisible to
coverage.py's line tracer once AutoGraph compiles it into a TF graph).

## [0.1.2] - 2026-08-06

Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# BITS for GAPS

[![CI](https://github.com/dowlinglab/bits_for_gaps/actions/workflows/ci.yml/badge.svg)](https://github.com/dowlinglab/bits_for_gaps/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/dowlinglab/bits_for_gaps/branch/main/graph/badge.svg)](https://codecov.io/gh/dowlinglab/bits_for_gaps)
[![PyPI](https://img.shields.io/pypi/v/bits_for_gaps.svg)](https://pypi.org/project/bits_for_gaps/)
[![Docs](https://readthedocs.org/projects/bits-for-gaps/badge/?version=latest)](https://bits-for-gaps.readthedocs.io/en/latest/?badge=latest)

Expand Down Expand Up @@ -70,6 +71,13 @@ docs/ Sphinx documentation (ReadTheDocs)
pytest -q
```

To measure coverage locally (scoped to `src/bits_for_gaps` -- `examples/`, `paper/`,
and `tests/` are repo-only and excluded from the denominator):

```bash
pytest --cov=bits_for_gaps --cov-report=term-missing
```

## Provenance

The research code behind the paper was originally developed in a private repository over the
Expand Down
18 changes: 16 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,27 @@ pip install -e .
## Development install

```bash
pip install -e ".[dev]" # adds pytest, pytest-cov
pytest -q # 204 passed, 2 deselected
pip install -e ".[dev]" # adds pytest, pytest-cov, ruff
pytest -q # 218 passed, 2 deselected
```

The 2 deselected tests need Julia (`@pytest.mark.vle`) -- run them with
`pytest -m vle` after installing the `[vle]` extra below. See {doc}`reproduce_paper`.

```{admonition} Coverage
:class: note
`pytest -q` alone never measures coverage -- it stays fast and its output stays
unchanged for a plain local run. Measure it explicitly, scoped to the shipped
package (`examples/`, `paper/`, and `tests/` are repo-only and excluded):

pytest --cov=bits_for_gaps --cov-report=term-missing

CI measures coverage this way on one Python version (3.12) per run and uploads it to
[Codecov](https://codecov.io/gh/dowlinglab/bits_for_gaps) (works without a token for
this public repo); `pyproject.toml`'s `[tool.coverage.report]` sets a `fail_under`
floor so coverage can't silently regress.
```

## `examples/` and `paper/` are repo-only

```{important}
Expand Down
27 changes: 27 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,35 @@ quote-style = "double"
testpaths = ["tests"]
# `vle` tests need the Julia/Clapeyron backend; deselect them by default so a plain
# `pytest -q` is green on the pure-Python core. Run them with `pytest -m vle`.
# Deliberately NOT `--cov=...` here: a plain `pytest -q` (what a contributor runs day
# to day) stays fast and its output stays unchanged. Measure coverage explicitly with
# `pytest --cov=bits_for_gaps --cov-report=term-missing` (see README.md/docs); CI wires
# that invocation in on one job so `fail_under` below actually gates something.
addopts = "-ra -m 'not vle'"
markers = [
"vle: requires the Julia/Clapeyron VLE example backend (deselected by default)",
"slow: slower end-to-end tests (still run by default)",
]

[tool.coverage.run]
# Measure only the shipped package -- examples/, paper/, and tests/ are repo-only
# (see docs/installation.md) and would otherwise dilute the denominator with code
# that was never meant to ship.
source = ["bits_for_gaps"]
branch = true

[tool.coverage.report]
# `pytest -m vle`'s 2 Julia-backed tests are deselected by default (see above), so
# default-suite coverage never reflects them -- that's expected, not a gap to chase.
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"def __repr__",
"if __name__ == .__main__.:",
]
# 100% measured on the default suite (`pytest -q --cov=bits_for_gaps --cov-branch`,
# 605 statements + 134 branches) as of the commit that added this config -- 1 point of
# slack for legitimate small variation (coverage.py branch-counting differences across
# supported Python versions), not to paper over a regression.
fail_under = 99
5 changes: 4 additions & 1 deletion src/bits_for_gaps/design.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ def full_factorial_design(
levels = int(np.ceil(n_total ** (1 / d)))
grid_unit = np.array(list(product(*[np.linspace(0, 1, levels) for _ in range(d)])))

if len(grid_unit) < n_total:
if len(grid_unit) < n_total: # pragma: no cover
# Unreachable for any (bounds, n_train, n_test): levels = ceil(n_total**(1/d))
# guarantees levels**d >= n_total. Defensive dead code -- see the NOTE in
# tests/unit/test_design.py.
raise ValueError(f"Factorial grid has {len(grid_unit)} points, need {n_total}.")
if len(grid_unit) > n_total:
rng = np.random.default_rng(seed)
Expand Down
7 changes: 5 additions & 2 deletions src/bits_for_gaps/entropy.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,11 @@ def cholesky(C: np.ndarray) -> np.ndarray:
-------
np.ndarray, shape (d, d)
``C`` inverse, computed as ``L^-T L^-1`` where ``C = L L^T`` -- more
numerically stable than a direct ``np.linalg.inv`` for the covariance matrices
that arise here (used by :func:`second_order_entropy`'s multivariate branch).
numerically stable than a direct ``np.linalg.inv`` for well-conditioned
covariance matrices. Not currently called elsewhere in this package (
:func:`second_order_entropy`'s multivariate branch uses ``np.linalg.inv``
directly); kept as a public, tested utility for callers who want the more
numerically stable inverse.
"""
L = np.linalg.cholesky(C)
L_inv = np.linalg.inv(L)
Expand Down
6 changes: 5 additions & 1 deletion src/bits_for_gaps/gp.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,11 @@ def run_mcmc(

@tf.function
def run_chain_fn(chain_seed):
return tfp.mcmc.sample_chain(
# This line genuinely executes on every HMC run (every integration test that
# calls run() exercises it, repeatedly) -- it shows as uncovered because
# @tf.function's AutoGraph compiles this body into a TF graph, which runs
# outside CPython's normal per-line trace hooks that coverage.py relies on.
return tfp.mcmc.sample_chain( # pragma: no cover
num_results=no_samples,
num_burnin_steps=no_burn_in,
current_state=hmc_helper.current_state,
Expand Down
14 changes: 14 additions & 0 deletions tests/integration/test_nd_synthetic.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,17 @@ def test_stable_across_two_runs_with_same_seed(run_a, run_b):
np.testing.assert_allclose(a.trace, b.trace, atol=1e-10)
np.testing.assert_allclose(a.xStar, b.xStar, atol=1e-10)
assert a.max_entropy == pytest.approx(b.max_entropy, abs=1e-10)


@pytest.mark.slow
def test_checkpoint_skips_entropy_file_for_non_2d(case, tmp_path):
# _write_checkpoint only writes entropy_{it} when record.entropy_field is not
# None -- which test_end_to_end.py's 2-D checkpoint test always has, so the d != 2
# (entropy_field is None) branch is otherwise never exercised.
X_init, y_init = _initial_design(case["bounds"], case["true_f"], n=10)
bfg = _build_bfg(case)
checkpoint_dir = tmp_path / "checkpoints"
bfg.run(X_init, y_init, checkpoint_dir=str(checkpoint_dir))
written = {p.name for p in checkpoint_dir.iterdir()}
assert {"rhat_value_1.txt", "ess_value_1.txt", "activity_data_2", "gp_model_1.pkl"} <= written
assert "entropy_1" not in written
25 changes: 22 additions & 3 deletions tests/unit/test_design.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,26 @@ def test_full_factorial_exact_grid_size_needs_no_trimming():
assert test.shape == (0, 2)


## NOTE: `full_factorial_design`'s "grid too small" `ValueError` (design.py:74-75) is
def test_full_factorial_overshoot_grid_is_trimmed_and_seed_dependent():
# d=2, n_total=10 -> levels = ceil(sqrt(10)) = 4 -> a 4x4=16-point grid, which
# overshoots n_total=10 and must be randomly trimmed (the `rng.choice` branch
# `test_full_factorial_exact_grid_size_needs_no_trimming` above doesn't reach,
# since 9 is a perfect square).
bounds = [(0.0, 1.0), (0.0, 1.0)]
train, test = full_factorial_design(bounds, n_train=10, n_test=0, seed=0)
assert train.shape == (10, 2)
assert test.shape == (0, 2)
assert train.min() >= 0.0 and train.max() <= 1.0
# No duplicate points -- confirms `replace=False` trimming, not resampling.
assert len(np.unique(train, axis=0)) == 10

# A different seed must select a different subset of the 16-point grid (proves
# the trim is actually seeded, not silently deterministic regardless of `seed`).
train_b, _ = full_factorial_design(bounds, n_train=10, n_test=0, seed=1)
assert not np.array_equal(np.sort(train, axis=0), np.sort(train_b, axis=0))


## NOTE: `full_factorial_design`'s "grid too small" `ValueError` (design.py:72-73) is
## unreachable for any (bounds, n_train, n_test): `levels = ceil(n_total ** (1/d))`
## guarantees `levels ** d >= n_total`. Defensive dead code, not a bug -- not exercised
## here since no input triggers it.
## guarantees `levels ** d >= n_total`. Defensive dead code, not a bug -- marked
## `# pragma: no cover` at the source rather than faked with a test.
19 changes: 19 additions & 0 deletions tests/unit/test_entropy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from scipy import stats

from bits_for_gaps.entropy import (
cholesky,
entropy_lower_bound,
first_order_entropy_approx,
gaussian_mixture_density,
Expand Down Expand Up @@ -124,3 +125,21 @@ def test_gaussian_mixture_density_multivariate_matches_scipy():
cov = np.diag([1.0, 0.5])
density = gaussian_mixture_density(x, means=[mean], covs=[cov], weights=[1.0])
assert density == pytest.approx(stats.multivariate_normal.pdf(x, mean=mean, cov=cov), rel=1e-12)


## ---------------------------------------------------------------------------
## cholesky: not currently called elsewhere in this package (second_order_entropy's
## multivariate branch uses np.linalg.inv directly) but a public, documented utility
## a caller could rely on -- tested directly on its own mathematical contract.
## ---------------------------------------------------------------------------


def test_cholesky_matches_direct_matrix_inverse():
C = np.array([[4.0, 1.0], [1.0, 3.0]])
np.testing.assert_allclose(cholesky(C), np.linalg.inv(C), rtol=1e-12)


def test_cholesky_result_is_a_true_inverse():
C = np.array([[2.0, 0.3, 0.1], [0.3, 1.5, 0.2], [0.1, 0.2, 1.0]])
C_inv = cholesky(C)
np.testing.assert_allclose(C @ C_inv, np.eye(3), atol=1e-12)
49 changes: 49 additions & 0 deletions tests/unit/test_init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Unit tests for ``bits_for_gaps``'s top-level PEP 562 lazy-import machinery.

Every other test in this suite reaches the TF-backed classes via
``from bits_for_gaps.kernels import AnisotropicSE``-style direct submodule imports,
which never exercises ``__getattr__``'s lazy-resolution branch or ``__dir__``. Both are
part of the module's actual public contract (documented in its module docstring), so
they're tested directly here rather than excluded from coverage.
"""

import pytest

import bits_for_gaps
from bits_for_gaps.kernels import AnisotropicSE
from bits_for_gaps.means import FixedInverseMean
from bits_for_gaps.sampler import BitsForGaps, adaptiveEntropy


def test_lazy_attribute_resolves_to_the_real_class():
# bits_for_gaps.AnisotropicSE (no direct submodule import) must be the exact same
# object __getattr__ resolves it to, not a copy or a different definition.
assert bits_for_gaps.AnisotropicSE is AnisotropicSE
assert bits_for_gaps.FixedInverseMean is FixedInverseMean
assert bits_for_gaps.adaptiveEntropy is adaptiveEntropy
assert bits_for_gaps.BitsForGaps is BitsForGaps


def test_lazy_attribute_importable_via_from_import():
# `from bits_for_gaps import BitsForGaps` is the documented top-level entry point
# (see docs/quickstart.md) -- it must go through the same __getattr__ path.
from bits_for_gaps import BitsForGaps as ReImported

assert ReImported is BitsForGaps


def test_unknown_attribute_raises_attribute_error():
with pytest.raises(AttributeError, match="no attribute 'NotARealName'"):
_ = bits_for_gaps.NotARealName


def test_dir_includes_lazy_names_and_eager_names():
names = dir(bits_for_gaps)
# Lazy (TF-backed) names, resolved only via __getattr__.
for lazy_name in ("AnisotropicSE", "FixedInverseMean", "adaptiveEntropy", "BitsForGaps"):
assert lazy_name in names
# Eager (pure NumPy/SciPy) names, already in the module's normal namespace.
for eager_name in ("second_order_entropy", "latin_hypercube_design", "design", "entropy"):
assert eager_name in names
# dir() must not report duplicates.
assert len(names) == len(set(names))
Loading
Loading