From 76aca2668aa5ce3932f06eb69ec10661e84c0b47 Mon Sep 17 00:00:00 2001 From: Remis Date: Tue, 1 Sep 2026 18:31:57 +0100 Subject: [PATCH 1/2] Exercise the dcio-backed entry points in CI read_idealized_bursts and log_bin_edges import dcio and scalcs at call time, so the likelihood library needs nothing but numpy. The cost of that design is that the CI matrix, which installs neither, could not exercise either entry point -- so both shipped with no coverage at all, and a green run said nothing about them. #180's delegation was verified only on my machine. Adds a dcio-integration job that installs both from git and runs tests against an installed package rather than the checkout. It builds through scikit-build-core, which is also what a user gets. The tests pin golden values for the shipped CH82 record (572 bursts, 1100 intervals, 4.819128 s), that every burst has an odd interval count, that the sign of tcrit does not change segmentation, the bins-per-decade rule, and that the histogram bars account for every interval. One of them matters more than the rest: importing HJCFIT in a subprocess must leave dcio and scalcs out of sys.modules, which is the property the lazy imports exist to provide and the one a future refactor is most likely to break. Also corrects the ImportError guidance. It pointed at github.com/remislp/dcio and github.com/remislp/SCALCS; both moved to the DCPROGS organisation and the SCALCS fork has been deleted, so the only advice the error gave was a dead link. It now gives the two pip commands. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 48 ++++++++++++ python/__init__.py | 10 ++- tests/test_dcio_integration.py | 136 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 tests/test_dcio_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4ab716..0562a98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,3 +173,51 @@ jobs: build/_deps/**/*.log if-no-files-found: ignore retention-days: 14 + + # read_idealized_bursts and log_bin_edges import dcio and scalcs at call time, + # so the likelihood library needs nothing but numpy. The cost of that design is + # that the matrix above, which installs neither, cannot exercise either entry + # point -- both shipped with no coverage at all, and a green run said nothing + # about them. This job installs both from git and runs the tests that do. + # + # It builds through scikit-build-core rather than the CMake matrix: pip install + # gives an installed package, which is also what a user has. + dcio-integration: + name: dcio-integration + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends build-essential swig libeigen3-dev libgmp-dev libmpfr-dev liblapack-dev gfortran ninja-build + + - name: Build and install HJCFIT + run: | + python -m pip install --upgrade pip + pip install . + + # Neither is on PyPI. When they are, these become ordinary pins. + - name: Install the optional record layer + run: | + pip install git+https://github.com/DCPROGS/dcio@master + pip install git+https://github.com/DCPROGS/SCALCS@master + pip install pytest matplotlib + pip list + + # Run from outside the source tree, so the installed package is what is + # imported rather than the checkout. + - name: Tests + env: + MPLBACKEND: Agg + run: | + cp tests/test_dcio_integration.py /tmp/ + cd /tmp + pytest test_dcio_integration.py -v diff --git a/python/__init__.py b/python/__init__.py index 74395d3..359fa74 100644 --- a/python/__init__.py +++ b/python/__init__.py @@ -75,9 +75,13 @@ def read_idealized_bursts(filename, tau, tcrit): except ImportError as e: raise ImportError( "read_idealized_bursts needs dcio and scalcs, which are not " - "dependencies of HJCFIT and are not on PyPI. Install them from " - "https://github.com/remislp/dcio and " - "https://github.com/remislp/SCALCS. (Original error: {0})".format(e)) + "dependencies of HJCFIT and are not on PyPI. Install them with +" + " pip install git+https://github.com/DCPROGS/dcio +" + " pip install git+https://github.com/DCPROGS/SCALCS +" + "(Original error: {0})".format(e)) if not exists(filename): # Not a path: check whether it names one of the sample records, with or diff --git a/tests/test_dcio_integration.py b/tests/test_dcio_integration.py new file mode 100644 index 0000000..88a4cd5 --- /dev/null +++ b/tests/test_dcio_integration.py @@ -0,0 +1,136 @@ +"""HJCFIT's optional dcio-backed entry points. + +read_idealized_bursts and log_bin_edges import dcio (and scalcs) at call time, +so that the likelihood library itself needs nothing but numpy. Nothing +exercised either of them: the delegation added in #180 and the burst reading +reworked in #170 both shipped with no coverage, and CI could not have caught a +break in either because dcio was never installed there. + +Run with dcio and scalcs installed; the CI job that does so is dcio-integration. +""" + +import sys + +import numpy as np +import pytest + +import HJCFIT +from HJCFIT.likelihood._methods import ( + ideal_pdf_scale_factor, + log_bin_edges, +) + +pytest.importorskip("dcio", reason="dcio-backed entry points need dcio") + + +class TestSoftDependency: + """The whole point of the lazy imports: dcio must not be pulled in just by + importing the library.""" + + def test_importing_hjcfit_does_not_import_dcio(self): + out = __import__("subprocess").run( + [sys.executable, "-c", + "import sys, HJCFIT, HJCFIT.likelihood; " + "print('dcio' in sys.modules or 'scalcs' in sys.modules)"], + capture_output=True, text=True, check=True) + assert out.stdout.strip() == "False", ( + "importing HJCFIT pulled in dcio or scalcs; they must stay lazy") + + +class TestReadIdealizedBursts: + + def test_ch82_sample(self): + """Golden values for the shipped CH82 record. + + Any change to dcio's resolution imposition or burst convention moves + these; that is what they are for.""" + bursts = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + assert len(bursts) == 572 + assert sum(len(b) for b in bursts) == 1100 + assert sum(b.sum() for b in bursts) == pytest.approx(4.819128, abs=1e-5) + + def test_every_burst_alternates_open_shut(self): + """Each burst starts and ends on an opening, so its interval count is + odd. The missed-events likelihood is a product of matrices alternating + A->F and F->A; an even-length burst would end on a shut.""" + bursts = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + assert all(len(b) % 2 == 1 for b in bursts) + + def test_tcrit_sign_does_not_change_segmentation(self): + """A negative tcrit is a flag to Log10Likelihood selecting equilibrium + vectors over CHS vectors; the magnitude is the critical time, and the + same number is passed to both.""" + pos = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=4e-3) + neg = HJCFIT.read_idealized_bursts("CH82", tau=1e-4, tcrit=-4e-3) + assert len(pos) == len(neg) + for a, b in zip(pos, neg): + np.testing.assert_allclose(a, b) + + def test_named_samples_all_readable(self): + for name in ("CH82", "CO", "CCO"): + bursts = HJCFIT.read_idealized_bursts(name, tau=1e-4, tcrit=4e-3) + assert len(bursts) > 0 + + +class TestLogBinEdges: + """Delegates to dcio.analysis.histogram (#180).""" + + def test_edges_cover_the_longest_interval(self): + x = np.random.default_rng(5).exponential(0.005, 2000) + 2.5e-5 + edges, nbdec = log_bin_edges(x, 2.5e-5) + assert x.max() <= edges[-1] + assert edges[0] == pytest.approx(2.5e-5) + + def test_nbdec_from_sample_size(self): + for n, expected in ((200, 5), (800, 8), (2000, 10), (8000, 12)): + x = np.full(n, 0.01) + _, nbdec = log_bin_edges(x, 1e-5) + assert nbdec == expected + + def test_nbdec_bins_span_a_decade(self): + edges, nbdec = log_bin_edges(np.full(2000, 0.01), 1e-5) + assert edges[nbdec] / edges[0] == pytest.approx(10.0) + + +class TestIdealPdfScaleFactor: + """Stays in HJCFIT: it renormalises an ideal pdf onto the resolved + intervals from the Q-matrix survival, which is not what EKDIST's + similarly-named helper computes.""" + + def test_unit_resolution_gives_unit_factor(self): + aa = np.array([[-1000.0]]) + phi = np.array([1.0]) + assert ideal_pdf_scale_factor(0.0, aa, phi) == pytest.approx(1.0) + + def test_factor_exceeds_one_for_positive_resolution(self): + aa = np.array([[-1000.0]]) + phi = np.array([1.0]) + assert ideal_pdf_scale_factor(1e-4, aa, phi) > 1.0 + + +class TestDwellTimeHistogram: + """The consumer of log_bin_edges. Needs matplotlib, which HJCFIT does not + depend on either.""" + + def test_returns_axes(self): + pytest.importorskip("matplotlib") + import matplotlib + matplotlib.use("Agg") + from HJCFIT.likelihood._methods import dwell_time_histogram + + x = np.random.default_rng(6).exponential(0.005, 2000) + 2.5e-5 + ax = dwell_time_histogram(x, 2.5e-5) + assert ax is not None + + def test_bars_account_for_every_interval(self): + pytest.importorskip("matplotlib") + import matplotlib + matplotlib.use("Agg") + from HJCFIT.likelihood._methods import dwell_time_histogram + + x = np.random.default_rng(7).exponential(0.005, 1500) + 2.5e-5 + ax = dwell_time_histogram(x, 2.5e-5) + # the outline is drawn as sqrt(counts); squaring recovers them, and + # each bar contributes its count twice + ydata = ax.lines[0].get_ydata() + assert round((ydata ** 2).sum() / 2) == len(x) From f9940ca7bbe1589d92a4cfdaf043f40a6259a283 Mon Sep 17 00:00:00 2001 From: Remis Date: Tue, 1 Sep 2026 18:48:27 +0100 Subject: [PATCH 2/2] Fix the ImportError message: it did not compile An escaped newline collapsed into a real one while editing, splitting the string literal across lines. python/__init__.py then failed to compile, which ctest caught as python_qmatrix and every matrix job reported. It passed locally because 'import HJCFIT' resolves to the installed package, not the source tree, so nothing I ran locally ever compiled the file I had edited. Compiling the sources directly is the check that catches this. Co-Authored-By: Claude Opus 5 --- python/__init__.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/python/__init__.py b/python/__init__.py index 359fa74..4429480 100644 --- a/python/__init__.py +++ b/python/__init__.py @@ -75,12 +75,9 @@ def read_idealized_bursts(filename, tau, tcrit): except ImportError as e: raise ImportError( "read_idealized_bursts needs dcio and scalcs, which are not " - "dependencies of HJCFIT and are not on PyPI. Install them with -" - " pip install git+https://github.com/DCPROGS/dcio -" - " pip install git+https://github.com/DCPROGS/SCALCS -" + "dependencies of HJCFIT and are not on PyPI. Install them with: " + "pip install git+https://github.com/DCPROGS/dcio -- and -- " + "pip install git+https://github.com/DCPROGS/SCALCS. " "(Original error: {0})".format(e)) if not exists(filename):