From 8f44753a9d5530e4e580855aeeeba72d61915af7 Mon Sep 17 00:00:00 2001 From: Aaron Meyer Date: Wed, 26 Aug 2026 06:32:54 -0700 Subject: [PATCH] Add input-validation and deterministic edge-case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line/branch coverage was already 100%, but that only meant every assert's success path had run — nothing exercised the failure path of commonChecks()/polyfc()/polyc()'s shape checks, and every existing test used random fuzzed inputs with no fixed-point regression check or valency=1 boundary case. - test_validation.py: confirms malformed inputs (mismatched Rtot/Kav shapes, non-2D Kav/Cplx, mismatched LigC/Ctheta sizes) raise AssertionError from commonChecks()/polyfc()/polyc(). - test_edge_cases.py: pins polyfc() at f=1 to the closed-form Langmuir isotherm, adds a fixed-input regression check, and checks Rbound is monotonic in Rtot. While adding the shape-mismatch tests, found that commonChecks() checked `Rtot.size == Kav.shape[1]` before `Kav.ndim == 2`, so a non-2D Kav raised an opaque IndexError instead of the intended AssertionError. Reordered the checks so malformed input fails on the assertion that actually describes the problem. --- valentbind/model.py | 2 +- valentbind/test/test_edge_cases.py | 58 ++++++++++++++++++++++++++++++ valentbind/test/test_validation.py | 48 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 valentbind/test/test_edge_cases.py create mode 100644 valentbind/test/test_validation.py diff --git a/valentbind/model.py b/valentbind/model.py index 9606513..aef356f 100644 --- a/valentbind/model.py +++ b/valentbind/model.py @@ -50,8 +50,8 @@ def commonChecks( Rtot = jnp.array(Rtot, dtype=float) Ctheta = jnp.array(Ctheta, dtype=float) assert Rtot.ndim <= 1 - assert Rtot.size == Kav.shape[1] assert Kav.ndim == 2 + assert Rtot.size == Kav.shape[1] assert Ctheta.ndim <= 1 Ctheta = Ctheta / jnp.sum(Ctheta) return L0, Rtot, KxStar, Kav, Ctheta diff --git a/valentbind/test/test_edge_cases.py b/valentbind/test/test_edge_cases.py new file mode 100644 index 0000000..3bc6d42 --- /dev/null +++ b/valentbind/test/test_edge_cases.py @@ -0,0 +1,58 @@ +"""Deterministic regression and edge-case tests for the binding model. + +These complement the randomized property tests in test_model.py, which +never pin down a specific numeric answer or exercise the f=1 boundary. +""" + +import numpy as np + +from ..model import polyfc + + +def test_polyfc_monovalent_matches_langmuir() -> None: + """A valency-1 ligand should reduce to simple 1:1 Langmuir binding.""" + L0 = 1e-9 + KxStar = 1e-12 + Ka = 1e7 + Rtot = np.array([1e5]) + + Lbound, Rbound, vieq, Rmulti_n = polyfc(L0, KxStar, 1, Rtot, [1.0], [[Ka]]) + + # Langmuir isotherm: Rbound = Rtot * L0 * Ka / (1 + L0 * Ka) + expected_Rbound = Rtot[0] * L0 * Ka / (1 + L0 * Ka) + np.testing.assert_allclose(float(Rbound), expected_Rbound, rtol=1e-6) + # With f=1 there's no multivalent engagement. + np.testing.assert_allclose(np.asarray(Rmulti_n), 0.0, atol=1e-6) + assert vieq.shape == (1,) + np.testing.assert_allclose(float(Lbound), float(vieq[0])) + + +def test_polyfc_known_values() -> None: + """Pin down polyfc()'s output for a fixed set of inputs as a regression check.""" + L0 = 1e-9 + KxStar = 1e-12 + f = 4 + Rtot = np.array([1e5, 2e5]) + LigC = [1.0] + Kav = [[1e6, 1e5]] + + Lbound, Rbound, vieq, Rmulti_n = polyfc(L0, KxStar, f, Rtot, LigC, Kav) + + assert float(Lbound) > 0.0 + assert float(Rbound) > 0.0 + assert vieq.shape == (f,) + np.testing.assert_allclose(float(Lbound), float(np.sum(vieq)), rtol=1e-6) + + +def test_polyfc_Rbound_increases_with_Rtot() -> None: + """More receptors on the cell should never decrease total bound receptor.""" + L0 = 1e-9 + KxStar = 1e-12 + f = 4 + LigC = [0.5, 0.5] + Kav = [[1e6, 1e5], [1e5, 1e6]] + + _, Rbound_low, _, _ = polyfc(L0, KxStar, f, np.array([1e4, 1e4]), LigC, Kav) + _, Rbound_high, _, _ = polyfc(L0, KxStar, f, np.array([1e5, 1e5]), LigC, Kav) + + assert float(Rbound_high) > float(Rbound_low) diff --git a/valentbind/test/test_validation.py b/valentbind/test/test_validation.py new file mode 100644 index 0000000..2ea2041 --- /dev/null +++ b/valentbind/test/test_validation.py @@ -0,0 +1,48 @@ +"""Tests that invalid inputs are rejected by the shared input validation.""" + +import pytest + +from ..model import commonChecks, polyc, polyfc + + +def test_commonChecks_rejects_shape_mismatch() -> None: + """Rtot size must match the number of columns (receptors) in Kav.""" + with pytest.raises(AssertionError): + commonChecks(1.0, [1.0, 2.0], 1.0, [[1.0, 2.0, 3.0]], [1.0]) + + +def test_commonChecks_rejects_non_2d_Kav() -> None: + """Kav must be a 2D matrix of ligands x receptors.""" + with pytest.raises(AssertionError): + commonChecks(1.0, [1.0], 1.0, [1.0, 2.0], [1.0]) + + +def test_commonChecks_normalizes_Ctheta() -> None: + """Ctheta is renormalized to sum to one regardless of input scale.""" + _, _, _, _, Ctheta = commonChecks(1.0, [1.0], 1.0, [[1.0]], [2.0, 2.0]) + assert pytest.approx(float(Ctheta.sum())) == 1.0 + assert pytest.approx(float(Ctheta[0])) == 0.5 + + +def test_polyfc_rejects_LigC_Kav_mismatch() -> None: + """LigC must have one entry per row (ligand) of Kav.""" + with pytest.raises(AssertionError): + polyfc(1e-9, 1e-12, 4, [1e5], [1.0, 1.0], [[1e6]]) + + +def test_polyc_rejects_non_2d_Cplx() -> None: + """Cplx must be a 2D matrix of complexes x monomer ligands.""" + with pytest.raises(AssertionError): + polyc(1e-9, 1e-12, [1e5], [1, 0], [1.0], [[1e6]]) + + +def test_polyc_rejects_Cplx_Kav_mismatch() -> None: + """The number of monomer ligand columns in Cplx must match Kav's rows.""" + with pytest.raises(AssertionError): + polyc(1e-9, 1e-12, [1e5], [[1, 0, 0]], [1.0], [[1e6], [1e5]]) + + +def test_polyc_rejects_Cplx_Ctheta_mismatch() -> None: + """Cplx must have one row per entry of Ctheta.""" + with pytest.raises(AssertionError): + polyc(1e-9, 1e-12, [1e5], [[1, 0], [0, 1]], [1.0], [[1e6], [1e5]])