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
13 changes: 11 additions & 2 deletions .github/actions/setup-pybnf/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,17 @@ runs:
# capability that needs a companion PyPI package (antimony>=3.1.1, pure
# python wheel), and it is what unskips the bngsim_antimony-marked
# tests, so install the extra rather than bare bngsim. Keep the pin in
# sync with pyproject.toml.
# sync with pyproject.toml -- and note that asking is no longer the only
# thing holding it there. This pin was set to 0.11.35 on 2026-07-24, when
# that was also pyproject's floor. pyproject then moved to 0.12.0 and to
# 0.12.2, on 2026-08-02 and 2026-08-04, and this line did not follow, so
# for three weeks CI tested against a bngsim older than PyBNF declared it
# needed and nothing said so. tests/test_packaging_metadata.py::
# test_ci_action_installs_every_dependency_pyproject_declares now reads
# both files and fails when they disagree.
BNGSIM_SPEC=()
if [ "$BNGSIM" = 'true' ]; then
BNGSIM_SPEC=('bngsim[antimony]>=0.11.35,<1')
BNGSIM_SPEC=('bngsim[antimony]>=0.15.0,<1')
fi
# The optional pybnf[jax] extra (jax + jaxlib + blackjax) -- the ADR-0059
# HMC reference sampler and the jax-gated gradient-assembly tests. Heavy
Expand Down Expand Up @@ -145,6 +152,8 @@ runs:
# heavy jax stack is instead gated behind the `jax` input above. Keep ALL
# of these version bounds in sync with pyproject.toml's [tests] extra --
# anything declared there but missing here silently reverts to a skip.
# tests/test_packaging_metadata.py checks that, for every requirement in
# this file and in pyproject.toml, both name the same version range.
uv pip install \
"${BNGSIM_SPEC[@]}" \
"${JAX_SPEC[@]}" \
Expand Down
16 changes: 11 additions & 5 deletions pybnf/_bngsim_caps.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,17 @@ def _empty_capabilities():
# So the flag resolves through three routes, first match wins, and each is
# reported by :func:`event_sens_probe` so a refusal can say how it decided:
#
# 1. ``features['event_sensitivities']`` -- a dedicated key. bngsim publishes no
# such key today; naming it here costs nothing and means the flag starts
# reading the real answer, in BOTH directions, on the first build that grows
# one. That is #558's ask 1, and it is why this route is checked first even
# though it never fires yet.
# 1. ``features['event_sensitivities']`` -- a dedicated key, and since lanl/bngsim#431
# a real one. It was named here before it existed, on the reasoning that doing so
# cost nothing and would start the flag reading the real answer, in BOTH directions,
# on the first build that grew one. bngsim 0.15.0 is that build, and the floor is now
# 0.15.0, so this route fires for every install resolved from PyPI. It publishes the
# key on every build, so a ``False`` here is an answer rather than a silence.
#
# Routes 2 and 3 are not dead. A floor binds what a resolver installs, not what a
# build claims, and a bngsim built from source between the version bump and the fixes
# declares a number it does not carry -- which is the whole of why ADR-0119 stopped
# reading version strings. Those routes are what such a build falls through to.
#
# 2. ``features['effective_ic_sensitivity']`` -- a WITNESS. This key is not the
# capability; it reports ``Model.effective_ic_sensitivity``, the dx(0)/dtheta
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ classifiers = [
"Topic :: Scientific/Engineering :: Bio-Informatics",
]
dependencies = [
"bngsim>=0.12.2,<1",
"bngsim>=0.15.0,<1",
"dask>=2024.1.0",
"distributed>=2024.1.0",
"libroadrunner>=1.6.0",
Expand All @@ -47,7 +47,7 @@ dependencies = [

[project.optional-dependencies]
antimony = [
"bngsim[antimony]>=0.12.2,<1",
"bngsim[antimony]>=0.15.0,<1",
]
# The PEtab math translator (pybnf.petab.formula, ADR-0035): an expression
# observableFormula is parsed/serialized through petab's sympy-backed math grammar
Expand Down
16 changes: 9 additions & 7 deletions tests/test_gradient_sens_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,11 +481,12 @@ def test_a_declined_model_is_reported_identically_cold_and_warm(tmp_path,
came back empty.

The verdict is the half ADR-0121 pins, and it must not depend on the cache. The
reason is prose, and the pin ``bngsim>=0.12.2,<1`` admits builds on both sides of a
recent upstream change: a build from before it generates no source on a warm cache
and so replays no reason, while a build that carries the note beside the cached
artifact replays the reason a cold build gave, in the same words. Both are fine
here, and a replayed reason is strictly more useful than silence.
reason is prose, and it used to be allowed to go missing: the pin ``bngsim>=0.12.2,<1``
admitted builds on both sides of the upstream change that writes the reason into a note
beside the cached artifact, so a warm cache could legitimately say nothing. The floor is
now ``bngsim>=0.15.0,<1``, which is the release that carries the note, so silence is no
longer one of the answers and the warm reason is asserted word for word against the cold
one.
"""
cold = _sbml_model(tmp_path, _DECLINED_LAW, 'declined').analytic_sens_rhs_status()
assert cold.analytic is False
Expand All @@ -502,8 +503,9 @@ def test_a_declined_model_is_reported_identically_cold_and_warm(tmp_path,
bngsim_sbml_model._ENGINE_TEMPLATE_WARM_ATTEMPTED.clear()
warm = _sbml_model(tmp_path, _DECLINED_LAW, 'declined2').analytic_sens_rhs_status()
assert warm.analytic is False, 'the verdict must not depend on the codegen cache'
assert warm.reasons in ([], cold.reasons), (
'a warm cache either says nothing or replays the cold reason word for word')
assert warm.reasons == cold.reasons, (
'the floor guarantees the note beside the cached artifact, so a warm cache must '
'replay the cold reason word for word rather than fall silent')


# --- the config surface ------------------------------------------------------- #
Expand Down
177 changes: 170 additions & 7 deletions tests/test_packaging_metadata.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,187 @@
import fnmatch
import os
import re
import subprocess
import sys
import textwrap
import tomllib
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
CI_ACTION = REPO_ROOT / '.github' / 'actions' / 'setup-pybnf' / 'action.yml'


def test_project_metadata_declares_python_floor_and_bngsim_dependency():
pyproject_path = Path(__file__).resolve().parents[1] / 'pyproject.toml'
pyproject_path = REPO_ROOT / 'pyproject.toml'
metadata = tomllib.loads(pyproject_path.read_text())
project = metadata['project']

assert project['requires-python'] == '>=3.11'
# bngsim >= 0.12.0: parameter_scan/bifurcate carry the equilibration's dx/dθ into the scan
# (lanl/bngsim#81) and resolve an on_point hook's own ∂x(0)/∂θ (lanl/bngsim#111), which make
# a scored PRE-EQUILIBRATED dose-response scan differentiable (#532). (0.11.35 added the
# steady-state SteadyStateResult.output_sensitivities of lanl/bngsim#12 for #478; 0.11.34 the
# native carried-state parameter_scan/bifurcate + named saved states of lanl/bngsim#11 for #474.)
assert 'bngsim>=0.12.2,<1' in project['dependencies']
# bngsim >= 0.15.0 is bought by a CONTRACT, not by a feature PyBNF wants. lanl/bngsim#431
# publishes `event_sensitivities` as a real capabilities() feature key. Until it existed
# PyBNF read `effective_ic_sensitivity` as a WITNESS for the same thing (ADR-0119), which
# was sound only because lanl/bngsim#155 happened to land a few commits after the event
# fixes -- a fact about commit ordering that stops being evidence the moment the two are
# decoupled, silently. Guessing wrong here is not symmetric: a build without those fixes
# does not refuse, it returns a finite gradient with a term missing, so the fit converges
# and reports a plausible number. 0.15.0 is the first release where PyBNF asks the real
# question. It also carries the codegen-cache decline reason PyBNF reads (#647), and, on
# the refusal side, a RuleMonkey re-vendor that declines a TotalRate rule where RuleMonkey
# and NFsim genuinely disagree rather than picking a reading.
# (0.12.2/0.12.0 bought the carried-state parameter_scan/bifurcate sensitivities of
# lanl/bngsim#81 and #111 for #532; 0.11.35 the steady-state
# SteadyStateResult.output_sensitivities of lanl/bngsim#12 for #478; 0.11.34 the native
# carried-state parameter_scan + named saved states of lanl/bngsim#11 for #474.)
assert 'bngsim>=0.15.0,<1' in project['dependencies']


def _canonical_name(name):
"""PEP 503 normalization, so `pytest-xdist` and `pytest_xdist` are one name."""
return re.sub(r'[-_.]+', '-', name).lower()


_REQUIREMENT = re.compile(
r'^([A-Za-z][A-Za-z0-9._-]*)' # distribution name
r'(?:\[[^\]]*\])?' # extras, which the two files are allowed to differ on
r'\s*(.*)$' # the version specifier, or nothing at all
)


def _split_requirement(text):
"""``'bngsim[antimony]>=0.15.0,<1'`` -> ``('bngsim', '>=0.15.0,<1')``.

Extras are dropped on purpose: the CI action installs ``bngsim[antimony]`` where
pyproject's runtime list declares bare ``bngsim``, and that difference is deliberate
(the extra is what unskips the bngsim_antimony-marked tests). The version range is
the part that has to agree. Returns None for a string that is not a requirement.
"""
match = _REQUIREMENT.match(text.strip())
if match is None:
return None
name, specifier = match.groups()
return _canonical_name(name), specifier.replace(' ', '')


def _requirements_quoted_in(text):
"""Every single-quoted requirement in a YAML file, keyed by distribution name.

The CI action is a shell script embedded in YAML, so its requirements are ordinary
single-quoted shell words -- in the `uv pip install` argument list, in the BNGSIM_SPEC
and JAX_SPEC arrays, and in the `petab-spec` input's default. Quotes are matched
within a single line and comment lines are dropped, because the surrounding YAML prose
is full of apostrophes ("pyproject.toml's", "the action's") that otherwise pair up with
each other and swallow the real strings.
"""
found = {}
for line in text.splitlines():
if line.lstrip().startswith('#'):
continue
for quoted in re.findall(r"'([^'\n]*)'", line):
parsed = _split_requirement(quoted)
if parsed is not None:
found.setdefault(parsed[0], parsed[1])
return found


def test_ci_action_installs_every_dependency_pyproject_declares():
"""The CI action and pyproject.toml must name the same version range for every package.

The action does not install PyBNF's dependencies by resolving pyproject.toml. It
installs a hand-written list that mirrors it, because a `bngsim: false` leg has to be
able to leave bngsim out and a re-resolve would pull it back in. Two hand-maintained
copies of one list drift, and this one did: the action pinned `bngsim>=0.11.35` while
pyproject declared `bngsim>=0.12.2`. The two were set to the same number on 2026-07-24;
pyproject moved twice over the following fortnight and the action never followed, so for
three weeks continuous integration tested against a bngsim older than the project said
it required. Nothing caught it. Both files carried a comment asking a human to keep them
in sync, which is what a comment can do.

The failure is quiet in both directions. Too low a floor means CI passes on a build the
published wheel will not accept. A package declared in pyproject's [tests] extra but
absent from the action does not fail a job either -- the suite that needs it
`importorskip`s and reports as skipped.
"""
metadata = tomllib.loads((REPO_ROOT / 'pyproject.toml').read_text())
declared = {}
for requirement in (
metadata['project']['dependencies']
+ metadata['project']['optional-dependencies']['tests']
):
name, specifier = _split_requirement(requirement)
declared[name] = specifier

installed = _requirements_quoted_in(CI_ACTION.read_text())

missing = sorted(name for name in declared if name not in installed)
assert not missing, (
'pyproject.toml declares these but %s never installs them, so the tests that '
'need them skip in CI instead of failing: %s' % (CI_ACTION.name, ', '.join(missing))
)

drifted = {
name: (declared[name], installed[name])
for name in declared
if declared[name] != installed[name]
}
assert not drifted, (
'the CI action and pyproject.toml disagree about a version range '
'(name: pyproject wants, action installs): %s' % drifted
)


def test_the_version_is_the_same_everywhere_it_is_written_down():
"""One release version, written by hand into four files, must agree in all of them.

pyproject.toml does not carry the number: it declares `dynamic = ["version"]` and reads
`pybnf.__version__`, which makes `pybnf/__init__.py` the source of truth and the built
wheel correct by construction. The other three are separate copies that a release has to
remember to bump, and nothing has been checking them. A stale `CITATION.cff` tells anyone
citing PyBNF the wrong version, and a stale `docs/conf.py` labels the published
documentation with the previous release.

The changelog is checked in the same breath because it is the same ritual and the same
mistake. `[Unreleased]` is promoted to a dated heading at release time, so the newest
versioned heading is the released version at every commit -- both before a release, when
the promotion has not happened and neither has the bump, and after it, when both have.
Bumping one without the other is what this catches.
"""
init_text = (REPO_ROOT / 'pybnf' / '__init__.py').read_text()
match = re.search(r'^__version__ = [\'"]([^\'"]+)[\'"]', init_text, re.MULTILINE)
assert match is not None, 'pybnf/__init__.py does not define __version__'
version = match.group(1)

# The mechanism that makes __init__.py authoritative. If this ever changes, the rest of
# this test is checking copies against something that is no longer the original.
metadata = tomllib.loads((REPO_ROOT / 'pyproject.toml').read_text())
assert 'version' in metadata['project']['dynamic']
assert metadata['tool']['setuptools']['dynamic']['version'] == {
'attr': 'pybnf.__version__'
}

citation = (REPO_ROOT / 'CITATION.cff').read_text()
assert re.search(r'^version: %s$' % re.escape(version), citation, re.MULTILINE), (
"CITATION.cff does not say version %s -- anyone citing PyBNF gets the wrong "
'release' % version
)

conf = (REPO_ROOT / 'docs' / 'conf.py').read_text()
assert re.search(r"^version = '%s'$" % re.escape(version), conf, re.MULTILINE), (
"docs/conf.py's `version` is not %s" % version
)
# `release` carries a leading v; `version` does not. Both are bumped by hand.
assert re.search(r"^release = 'v%s'$" % re.escape(version), conf, re.MULTILINE), (
"docs/conf.py's `release` is not v%s" % version
)

changelog = (REPO_ROOT / 'CHANGELOG.md').read_text()
headings = re.findall(r'^## \[v?([^\]]+)\]', changelog, re.MULTILINE)
released = [h for h in headings if h != 'Unreleased']
assert released, 'CHANGELOG.md has no released version heading'
assert released[0] == version, (
"CHANGELOG.md's newest released heading is %s but __version__ is %s -- one of the "
'two was bumped without the other' % (released[0], version)
)


def test_every_pybnf_subpackage_is_shipped():
Expand Down
8 changes: 6 additions & 2 deletions tests/test_sbml_solver_tolerances.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,8 +925,12 @@ def test_the_backend_default_atol_is_what_corrupted_those_sensitivities(tmp_path
simply less catastrophic than it was.

So each threshold now sits a decade under the *worse* of the two measurements -- the
rule the two-scale test above states -- and both hold across the declared
``bngsim>=0.12.2,<1`` range rather than on the one release that drew them.
rule the two-scale test above states -- rather than on the one release that drew them.
The declared range is now ``bngsim>=0.15.0,<1``, which puts both of those measurements
below the floor. That leaves the guard conservative rather than stale: raising the floor
removed the worse end of the pair, and a threshold set a decade under the worse end is
still under the better one. It has not been re-derived on 0.15.0, and it does not need
to be until a release moves the numbers back up.
"""
model = _piecewise_model(tmp_path, atol=_BNGSIM_DEFAULT_ATOL)
model.enable_output_sensitivities(params=['k0', 'k1', 'k2'])
Expand Down
Loading