From a6c1fd6afe932f0d17f14081b83b5ac32eb8a082 Mon Sep 17 00:00:00 2001 From: jam-sudo Date: Tue, 22 Sep 2026 23:36:38 -0400 Subject: [PATCH] fix(renal): account for passive tubular reabsorption Use the published one-region tubule balance with the existing permeability estimate. Anchor the effective surface area to the Scotcher calibration point, without fitting to Cmax loss. Document the model limits and out-of-scope mechanisms, add focused checks, and record the headline-neutral validation. --- .../renal_reabsorption_2026-09-22.json | 174 ++++++++++++++++++ docs/research/experiment-log.md | 40 +++- src/sisyphus/engine/flux.py | 2 +- src/sisyphus/mipd/covariates.py | 15 +- src/sisyphus/predict/ivive.py | 61 +++++- tests/unit/test_adme_ivive.py | 44 +++++ 6 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 data/validation/renal_reabsorption_2026-09-22.json diff --git a/data/validation/renal_reabsorption_2026-09-22.json b/data/validation/renal_reabsorption_2026-09-22.json new file mode 100644 index 0000000..1252ff5 --- /dev/null +++ b/data/validation/renal_reabsorption_2026-09-22.json @@ -0,0 +1,174 @@ +{ + "date": "2026-09-22", + "change": "renal CL: filtration-only -> filtration + passive tubular reabsorption", + "model": "CL_renal = fup*UF*(GFR+PS)/(UF+PS), PS = peff*TSA", + "source": { + "ref": "Scotcher et al. 2016, Eur J Pharm Sci 94:59-71", + "doi": "10.1016/j.ejps.2016.03.018", + "note": "one-region reduction of the published 5-compartment model; verified algebraically identical to fup*GFR*(1-F_reabs), F_reabs=F'(1-UF/GFR), F'=PS/(PS+UF)" + }, + "constants": { + "GFR_L_PER_H": 7.5, + "URINE_FLOW_L_PER_H": 0.06, + "TUBULAR_SURFACE_AREA_CM2": 57.8, + "tsa_provenance": "back-calculated from the paper's F'=0.5 anchor (Caco-2 Papp 14.8e-6 cm/s) through adme._CACO2_TO_INVIVO_OFFSET; effective lumped area, NOT fitted to Cmax loss (Invariant #8)" + }, + "renal_cl_vs_clinical": { + "caffeine": { + "fup": 0.7009, + "peff": 9.105, + "clinical_cl_renal_l_h": 0.1, + "old_l_h": 5.257, + "new_l_h": 1.296, + "old_fold": 52.57, + "new_fold": 12.96 + }, + "acetaminophen": { + "fup": 0.7585, + "peff": 5.303, + "clinical_cl_renal_l_h": 0.6, + "old_l_h": 5.688, + "new_l_h": 2.033, + "old_fold": 9.48, + "new_fold": 3.39 + }, + "antipyrine": { + "fup": 0.5113, + "peff": 11.448, + "clinical_cl_renal_l_h": 0.15, + "old_l_h": 3.835, + "new_l_h": 0.796, + "old_fold": 25.56, + "new_fold": 5.31 + }, + "theophylline": { + "fup": 0.5796, + "peff": 6.569, + "clinical_cl_renal_l_h": 0.4, + "old_l_h": 4.347, + "new_l_h": 1.35, + "old_fold": 10.87, + "new_fold": 3.38 + }, + "midazolam": { + "fup": 0.0318, + "peff": 7.563, + "clinical_cl_renal_l_h": 0.02, + "old_l_h": 0.239, + "new_l_h": 0.067, + "old_fold": 11.94, + "new_fold": 3.36 + }, + "atenolol": { + "fup": 0.3394, + "peff": 0.812, + "clinical_cl_renal_l_h": 8.0, + "old_l_h": 2.546, + "new_l_h": 1.991, + "old_fold": 0.32, + "new_fold": 0.25 + }, + "gabapentin": { + "fup": 0.946, + "peff": 4.819, + "clinical_cl_renal_l_h": 6.0, + "old_l_h": 7.095, + "new_l_h": 2.691, + "old_fold": 1.18, + "new_fold": 0.45 + }, + "metformin": { + "fup": 0.8573, + "peff": 0.229, + "clinical_cl_renal_l_h": 31.0, + "old_l_h": 6.43, + "new_l_h": 5.961, + "old_fold": 0.21, + "new_fold": 0.19 + }, + "lisinopril": { + "fup": 0.1461, + "peff": 0.605, + "clinical_cl_renal_l_h": 6.0, + "old_l_h": 1.096, + "new_l_h": 0.907, + "old_fold": 0.18, + "new_fold": 0.15 + } + }, + "holdout_107_public_clone": { + "note": "local stack reproduces the committed cache bit-identically", + "baseline": { + "meta": { + "aafe": 2.7428, + "pct_2fold": 44.9, + "pct_3fold": 63.6 + }, + "engine": { + "aafe": 4.2779, + "pct_2fold": 27.1, + "pct_3fold": 43.9 + }, + "ml": { + "aafe": 2.9981, + "pct_2fold": 43.0, + "pct_3fold": 58.9 + } + }, + "renal": { + "meta": { + "aafe": 2.7416, + "pct_2fold": 44.9, + "pct_3fold": 63.6 + }, + "engine": { + "aafe": 4.2548, + "pct_2fold": 27.1, + "pct_3fold": 43.0 + }, + "ml": { + "aafe": 2.9981, + "pct_2fold": 43.0, + "pct_3fold": 58.9 + } + }, + "delta_meta": -0.0012, + "delta_engine": -0.0232, + "in_domain_n": 81, + "in_domain_meta": [ + 2.7906, + 2.7894 + ], + "n_engine_cmax_moved": 37, + "max_move": "<=4%" + }, + "pkdb_curve_fold_error": { + "note": "open-licence PK-DB timecourses, old vs new renal on the same canonical engine", + "caffeine_oral": [ + 28.04, + 24.91 + ], + "acetaminophen_iv": [ + 32.67, + 29.2 + ], + "acetaminophen_oral": [ + 22.45, + 21.36 + ], + "midazolam_iv": [ + 1.96, + 1.95 + ], + "midazolam_oral": [ + 2.01, + 2.01 + ] + }, + "not_modelled": [ + "active tubular secretion", + "pH/ionisation-dependent reabsorption", + "peff uncertainty in the returned cv" + ], + "cache_regen": "required on the canonical Linux stack; delta is within the +/-0.020 pin so the existing cache still passes" +} \ No newline at end of file diff --git a/docs/research/experiment-log.md b/docs/research/experiment-log.md index ad6e9a1..e24223d 100644 --- a/docs/research/experiment-log.md +++ b/docs/research/experiment-log.md @@ -1,5 +1,5 @@ --- -last_updated: 2026-07-03 +last_updated: 2026-09-22 parent: ../../README.md charter: Chronological log of Sisyphus experiments (successes, negatives, infrastructure). Latest first. --- @@ -10,6 +10,44 @@ Reverse-chronological. The project README carries only the **current** headline --- +## 2026-09-22 — Passive tubular reabsorption: renal CL is no longer filtration-only (correctness; headline-neutral) + +`_estimate_renal_clearance` was `CL_renal = GFR·fup` for every drug — the zero-permeability +limit, i.e. everything filtered is excreted and nothing is reabsorbed. That over-predicts renal +CL by ~an order of magnitude for permeable, high-fup drugs, which are filtered freely and then +almost entirely reabsorbed. Replaced with the one-compartment (well-stirred) tubule balance + + CL_renal = fup · UF · (GFR + PS) / (UF + PS), PS = peff · TSA + +reusing the already-predicted `peff`; `UF` = 1 mL/min urine flow. This is algebraically identical +(verified to machine precision) to the Scotcher et al. 2016 form `fup·GFR·(1−F_reabs)` with +`F_reabs = F'(1−UF/GFR)`, `F' = PS/(PS+UF)`, reduced to a single tubular region +(Eur J Pharm Sci 94:59-71, doi:10.1016/j.ejps.2016.03.018). Limits are exact: `PS→0` recovers +the old `fup·GFR`, `PS→∞` gives `fup·UF`. `TSA = 57.8 cm²` is back-calculated from that paper's +own model calibration point (F′=0.5 at Caco-2 Papp 14.8e-6 cm/s) through the existing +`_CACO2_TO_INVIVO_OFFSET`; it is an *effective* lumped area (a well-stirred tubule over-reabsorbs +vs the 5-region plug-flow structure, and the lumping difference lands here). **Anchored to the +published renal model only — never fitted to Cmax loss (Invariant #8).** + +- **Renal CL vs clinical** (fold error, old → new): caffeine 52.6→13.0, antipyrine 25.6→5.3, + theophylline 10.9→3.4, paracetamol 9.5→3.4, midazolam 11.9→3.4. Secretion-dominant drugs + (metformin, atenolol, lisinopril) are unchanged-and-still-under-predicted — active secretion + is explicitly out of scope and now documented as such. +- **Headline: neutral.** 107-holdout, public-clone, local stack (reproduces the committed cache + bit-identically): Meta 2.7428 → **2.7416** (−0.0012), Engine 4.2779 → 4.2548 (−0.0232), ML + unchanged. Meta %2-fold and %3-fold are unchanged; Engine %2-fold is unchanged and %3-fold + moves 43.9% → 43.0%. 37/107 engine Cmax values move, all ≤4%. Consistent + with DE-43 (the fixed-weight meta damps engine moves) — renal is a minor elimination route for + most holdout drugs. Ships on correctness, not on the number. +- **PK-DB curve check** (open-licence timecourses, old vs new on the same canonical engine): + curve fold error improves caffeine 28.0→24.9, paracetamol IV 32.7→29.2, oral 22.5→21.4; + midazolam unchanged (1.96→1.95). Directionally right but small — the dominant residual on + those two curves is the CLint assay-floor artifact, not renal (DE-58). + +The committed `4track_holdout_predictions.json` is **not** regenerated here (macOS is not the +canonical numerics stack); the Δ is within the ±0.020 pin so `test_cached_holdout_aafe_is_2p743` +still passes against the existing cache. A canonical Linux regen is the follow-up. + ## 2026-07-07 (cont.) — N50' clean re-curation: infeasible from repo data (pool=0), deferred to human-led curation Scoped the clean N50' secondary-holdout curation (the unbiased-generalization instrument the diff --git a/src/sisyphus/engine/flux.py b/src/sisyphus/engine/flux.py index 8890c74..2adb848 100644 --- a/src/sisyphus/engine/flux.py +++ b/src/sisyphus/engine/flux.py @@ -315,7 +315,7 @@ def apply( renal_cl = params.drug_param("renal_clearance") if renal_cl <= 0: return - # RBP-2: renal_cl = GFR·fup is plasma-basis → filter PLASMA A/(V·Kp), + # RBP-2: renal_cl (fup-scaled, ivive.py) is plasma-basis → filter PLASMA A/(V·Kp), # not blood. The convective edge supplies flow limitation. v = params.node_param(self.source_name, "volume") kp = params.drug_kp(self.source_name) diff --git a/src/sisyphus/mipd/covariates.py b/src/sisyphus/mipd/covariates.py index aa85cab..486a906 100644 --- a/src/sisyphus/mipd/covariates.py +++ b/src/sisyphus/mipd/covariates.py @@ -1,10 +1,14 @@ """Patient covariates that deterministically individualize the engine prior. v1: renal function only — a measured creatinine clearance (CrCl). CrCl scales -the drug's renal (glomerular-filtration) clearance: the engine's reference renal -model is ``CL_renal = GFR*fup`` with GFR = 7.5 L/h (~125 mL/min), so an -individual's renal CL is scaled by ``CrCl / 125``. Weight/age covariates (via -sbi.physiology_generator) are a documented future extension — see the design spec +the drug's renal clearance by ``CrCl / 125`` (reference GFR = 7.5 L/h ~= 125 +mL/min). The engine's reference renal model is filtration with passive tubular +reabsorption, ``CL_renal = fup*UF*(GFR+PS)/(UF+PS)`` (predict/ivive.py). The +linear CrCl scaling is exact in the filtration-dominated limit (small PS) and +over-scales highly reabsorbed drugs, whose CL_renal (-> fup*UF) is nearly +GFR-independent — but their renal CL is then negligible in absolute terms. +Weight/age covariates (via sbi.physiology_generator) are a documented future +extension — see the design spec docs/_internal/specs/2026-06-11-mipd-crcl-renal-individualization-design.md. """ from __future__ import annotations @@ -124,7 +128,8 @@ def warnings(self) -> tuple[str, ...]: if self.crcl_ml_min is not None and not (5.0 <= self.crcl_ml_min <= 200.0): w.append( f"crcl:extreme:{self.crcl_ml_min}: the engine renal model is " - "glomerular-filtration-only and least reliable outside [5, 200] mL/min" + "filtration + passive reabsorption only (no active secretion) and least " + "reliable outside [5, 200] mL/min" ) if self.body_weight_kg is not None and not (2.0 <= self.body_weight_kg <= 250.0): w.append( diff --git a/src/sisyphus/predict/ivive.py b/src/sisyphus/predict/ivive.py index a6daa06..0400989 100644 --- a/src/sisyphus/predict/ivive.py +++ b/src/sisyphus/predict/ivive.py @@ -42,6 +42,26 @@ # GFR: glomerular filtration rate (L/h) _GFR_L_PER_H = 7.5 # ~125 mL/min = 7.5 L/h +# Urine flow at the end of the nephron (L/h). Scotcher et al. 2016, +# Eur J Pharm Sci 94:59-71 (doi:10.1016/j.ejps.2016.03.018) assume 1 mL/min. +_URINE_FLOW_L_PER_H = 0.06 # 1 mL/min + +# Effective lumped tubular surface area (cm^2) for scaling peff -> PS. +# Back-calculated from the Scotcher 2016 mechanistic-model calibration point: +# their 5-compartment model reaches F' = 0.5 (half of urine/plasma equilibrium) +# at Caco-2 Papp = 14.8e-6 cm/s under a pH 6.5/7.4 gradient. In the +# one-compartment reduction used below F' = PS/(PS + UF), so F' = 0.5 means +# PS = UF = 0.06 L/h. Sisyphus `peff` is in-vivo-calibrated Caco-2 +# (peff = 10**1.29 * Papp ~= 19.5x, adme._CACO2_TO_INVIVO_OFFSET), putting the +# half-reabsorption point at peff = 2.886e-4 cm/s, hence +# TSA = 0.06 L/h * 1000 cm3/L / (2.886e-4 cm/s * 3600 s/h) = 57.8 cm^2. +# This is an EFFECTIVE area, well below the geometric proximal-tubule area: a +# well-stirred tubule sees fully concentrated filtrate everywhere and so +# over-reabsorbs relative to Scotcher's 5-region plug-flow-with-water-removal +# structure, and the lumping difference is absorbed here. Anchored to the +# published renal model ONLY — never fitted to Sisyphus Cmax loss (Invariant #8). +_TUBULAR_SURFACE_AREA_CM2 = 57.8 + # --------------------------------------------------------------------------- # Enzyme abundances (pmol, total organ) — from reference_man.yaml # --------------------------------------------------------------------------- @@ -539,23 +559,46 @@ def _compute_all_kp( # --------------------------------------------------------------------------- -def _estimate_renal_clearance(fup: Distribution, profile: MolecularProfile) -> Distribution: - """Estimate renal clearance from GFR and fraction unbound. +def _estimate_renal_clearance(fup: Distribution, peff: Distribution) -> Distribution: + """Estimate renal clearance from filtration and passive tubular reabsorption. + + One-compartment (well-stirred) tubule mass balance: drug enters by + glomerular filtration (``fup * GFR``), leaves in urine (``UF * C_tubule``), + and exchanges passively across the tubular wall in both directions + (``PS * (C_tubule - fup * C_plasma)``). At steady state this gives + + CL_renal = fup * UF * (GFR + PS) / (UF + PS) - For drugs undergoing glomerular filtration without active secretion: - CL_renal = GFR * fup + algebraically identical to the Scotcher et al. 2016 formulation + ``CL = fup * GFR * (1 - F_reabs)``, ``F_reabs = F' * (1 - UF/GFR)``, + ``F' = PS / (PS + UF)``, reduced to a single tubular region. - Active secretion/reabsorption would require transporter data. + Limits: ``PS -> 0`` recovers ``fup * GFR`` (impermeable; filtration only), + ``PS -> inf`` gives ``fup * UF`` (equilibrates with plasma, so the drug + leaves only as fast as water does). The previous model was the ``PS = 0`` + limit applied to *every* drug, which over-predicted renal clearance for + permeable, high-fup compounds (e.g. caffeine, paracetamol) by ~an order of + magnitude — they are filtered freely but almost entirely reabsorbed. + + NOT modelled: active secretion (so secretion-dominant drugs are still + under-predicted), pH/ionisation-dependent reabsorption, and peff + uncertainty — the returned cv tracks fup only. Args: fup: Fraction unbound in plasma. - profile: Molecular profile (reserved for future use, e.g. charge-based - reabsorption estimates). + peff: Effective permeability (x10^-4 cm/s). Returns: Renal clearance (L/h) as Distribution. """ - cl_renal = _GFR_L_PER_H * fup.mean + # peff (x10^-4 cm/s) * TSA (cm^2) -> L/h + ps_l_per_h = peff.mean * 1e-4 * 3600.0 * _TUBULAR_SURFACE_AREA_CM2 / 1000.0 + cl_renal = ( + fup.mean + * _URINE_FLOW_L_PER_H + * (_GFR_L_PER_H + ps_l_per_h) + / (_URINE_FLOW_L_PER_H + ps_l_per_h) + ) return Distribution(mean=cl_renal, cv=fup.cv) @@ -748,7 +791,7 @@ def build_drug_on_graph( particle_radius = _estimate_particle_radius(adme.solubility) # Estimate renal clearance - renal_cl = _estimate_renal_clearance(adme.fup, profile) + renal_cl = _estimate_renal_clearance(adme.fup, adme.peff) # Set administration node based on route if route == "oral": diff --git a/tests/unit/test_adme_ivive.py b/tests/unit/test_adme_ivive.py index 72b11fa..97e2b14 100644 --- a/tests/unit/test_adme_ivive.py +++ b/tests/unit/test_adme_ivive.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + from sisyphus.core import Distribution from sisyphus.predict.chemistry import compute_profile @@ -191,6 +193,48 @@ def test_renal_clearance_non_negative(self): drug = build_drug_on_graph(profile, adme, dose_mg=2.0) assert drug.renal_clearance.mean >= 0 + def test_renal_reabsorption_limits(self): + """CL_renal must interpolate between filtration-only and urine-flow limits. + + Scotcher et al. 2016 one-compartment reduction: PS -> 0 recovers + fup*GFR (impermeable), PS -> inf gives fup*UF (fully equilibrated), + and CL_renal decreases monotonically in between. + """ + from sisyphus.predict.ivive import ( + _GFR_L_PER_H, + _URINE_FLOW_L_PER_H, + _estimate_renal_clearance, + ) + + fup = Distribution(mean=0.7, cv=0.3) + cl = [ + _estimate_renal_clearance(fup, Distribution(mean=p, cv=0.0)).mean + for p in (1e-9, 0.1, 1.0, 10.0, 1e9) + ] + assert cl[0] == pytest.approx(fup.mean * _GFR_L_PER_H, rel=1e-6) + assert cl[-1] == pytest.approx(fup.mean * _URINE_FLOW_L_PER_H, rel=1e-6) + assert all(a > b for a, b in zip(cl, cl[1:])), f"not monotone: {cl}" + + def test_renal_reabsorption_half_point(self): + """peff at the Scotcher F'=0.5 anchor must halve the equilibrium gap. + + At PS == urine flow the model sits exactly midway (in the F' sense) + between the two limits, which pins _TUBULAR_SURFACE_AREA_CM2. + """ + from sisyphus.predict.ivive import ( + _GFR_L_PER_H, + _URINE_FLOW_L_PER_H, + _estimate_renal_clearance, + ) + + fup = Distribution(mean=1.0, cv=0.0) + # Caco-2 Papp 14.8e-6 cm/s * 10**1.29 in-vivo offset -> peff x10^-4 cm/s + peff_half = 14.8e-6 * 10**1.29 * 1e4 + cl = _estimate_renal_clearance(fup, Distribution(mean=peff_half, cv=0.0)).mean + uf = _URINE_FLOW_L_PER_H + expected = uf * (_GFR_L_PER_H + uf) / (2 * uf) + assert cl == pytest.approx(expected, rel=0.01) + def test_compound_type_preserved(self): """compound_type should pass through from MolecularProfile.""" from sisyphus.predict.adme import predict_adme