Skip to content
Open
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
174 changes: 174 additions & 0 deletions data/validation/renal_reabsorption_2026-09-22.json
Original file line number Diff line number Diff line change
@@ -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"
}
40 changes: 39 additions & 1 deletion docs/research/experiment-log.md
Original file line number Diff line number Diff line change
@@ -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.
---
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sisyphus/engine/flux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 10 additions & 5 deletions src/sisyphus/mipd/covariates.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
61 changes: 52 additions & 9 deletions src/sisyphus/predict/ivive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute renal clearance after Peff posterior updates

When SBI or TDM varies Peff, apply_theta_to_drug, _build_drug_from_3d, and _apply_multipliers replace drug.peff while deliberately retaining the nominal renal_clearance. After making clearance depend on peff.mean here, those posterior particles therefore simulate absorption with the updated Peff but elimination with clearance derived from a different Peff, biasing inference for high-fup or renally cleared drugs. Recompute renal clearance whenever these paths update Peff, or derive it dynamically from the current drug parameters.

Useful? React with 👍 / 👎.

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)


Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retrain the surrogate with the new renal model

The shipped surrogate and its training generator still use filtration-only clearance: scripts/train_surrogate.py:123 constructs renal_clearance=7.5*fup, whereas production drugs now receive this Peff-dependent value. Because the training data made renal clearance collinear with fup and never modeled its Peff interaction, the existing range guard can accept these inputs while returning predictions learned from the old elimination dynamics; even rerunning the current training script would preserve the mismatch. Update the generator and regenerate the surrogate artifacts before using the surrogate MC/TDM backends.

Useful? React with 👍 / 👎.


# Set administration node based on route
if route == "oral":
Expand Down
Loading
Loading