Skip to content

Commit 511dd7c

Browse files
committed
src/bridge: Campaign-1 → Campaign-2 rate-law emitter (API surface only)
The bridge module retires the prof's 'closed ontology / circular validation' critique by giving Campaign-2 pathway models a single import surface for binding rate constants WITH explicit method provenance and calibrated uncertainty. Today's commit ships the API contract + closed-form thermodynamic conversions, NOT any pathway simulation work (per the prof's gate that no Campaign-2 work proceeds until Milestone A clears): binding_to_hill(dG_kcalmol, *, T_K, n_hill, uncertainty_kcalmol) Hill equation, K_d = exp(ΔG/RT). σ propagates multiplicatively on K_d (95% CI = K_d × exp(±1.96σ/RT)). Source tag on the returned RateLawPrior distinguishes 'FEP' (today's compute_absolute_binding_dg output, calibrated σ) from 'phenomenological' (the OLD/ Tier-0 BindingMatcher descriptor-fit, inflated σ). affinity_to_michaelis(kcat, KM, *, ...) Pass-through for now. Symbolic placeholder for when CellSim grows a reactive-FEP / Eyring transition-state module. RateLawPrior dataclass Carries: type ('hill' | 'michaelis'), parameters dict, 95% CI per parameter, source ('FEP' | 'phenomenological' | 'literature'), method tag ('amber14-DDM-MBAR'), temperature, ISO timestamp. Campaign-2 ODE configs ingest this and audit-trace back to a specific FEP run. 6/6 smoke tests, CI-wired: - K_d matches closed-form thermodynamics - σ propagates multiplicatively (asymmetric CI on log scale) - No σ → empty CI (consumer can detect uncalibrated estimate) - Phenomenological source tag preserved - Michaelis pass-through correct - One-line summary stable Out-of-scope for Campaign 1 (matches src/bridge/README.md): - No pathway simulation (Campaign 2 work). - No bulk emission to a Campaign-2 YAML config (Campaign 2 work). - No SQLite cache integration (Campaign 2 will own the lookup layer; this module is a pure-function converter). When Campaign 2 resumes after Milestone A clears, its first pathway model imports from src.bridge and gets calibrated FEP- derived priors instead of hand-tuned phenomenology — exactly the cycle-break the prof asked for.
1 parent 68aa1b3 commit 511dd7c

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

.github/workflows/smoke.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ jobs:
181181
- name: fep-binding bench --resume regression
182182
run: python -u tests/fep/test_bench_resume_smoke.py
183183

184+
- name: bridge — Campaign-1 → Campaign-2 rate-law emitter
185+
run: python -u tests/bridge/test_binding_to_hill_smoke.py
186+
184187
- name: fep sampled binding smoke (opt-in, ~10 min, manual)
185188
if: >
186189
github.event_name == 'workflow_dispatch' &&

src/bridge/__init__.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""src/bridge — Campaign-1 → Campaign-2 rate-law emitter.
2+
3+
This module is the EXIT PATH from the professor's "closed
4+
ontology / circular validation" critique. Campaign-2 pathway
5+
models will not hand-tune binding rate constants; they will
6+
cite this module's output with explicit method provenance and
7+
calibrated uncertainty.
8+
9+
What ships here today (Campaign-1 scope)
10+
----------------------------------------
11+
- `binding_to_hill(dG_kcalmol, *, T_K=298.15, n_hill=1.0,
12+
uncertainty_kcalmol=None)` — convert an absolute binding ΔG
13+
(typically from `cellsim fep-binding bench --sample`) into a
14+
Hill-equation rate-law record with CI.
15+
- `affinity_to_michaelis(kcat_per_s, KM_M, *,
16+
uncertainty_kcat=None, uncertainty_KM=None)` — same idea for
17+
enzyme kinetics. Pass-through for now since FEP doesn't
18+
produce kcat directly; the module exists so Campaign-2
19+
loaders have a single place to import from.
20+
21+
Provenance dataclass (`RateLawPrior`) carries:
22+
- the rate-law type ('hill' | 'michaelis')
23+
- the parameter values + CI (95%)
24+
- source ('FEP', 'phenomenological', 'literature')
25+
- method tag ('amber14-DDM-MBAR' or whatever produced the input)
26+
- timestamp
27+
28+
Why a thin module: Campaign-2's signalling ODEs read these
29+
priors at config time. Centralising the conversion + provenance
30+
here means any Campaign-2 model can be audited back to a
31+
specific FEP run; if we later switch from amber14 to ff19SB,
32+
the audit trail tells us which rate-law records need re-emission.
33+
34+
Scope NOT in this commit (Campaign-2 work, gated by prof's
35+
Milestone-A clearance):
36+
- Anything that runs a cell-level ODE.
37+
- Bulk emission to a Campaign-2 YAML config.
38+
- Cache integration (the `(ligand_hash, receptor_hash) → ΔG`
39+
lookup that lets Campaign 2 ingest 10⁴ compounds).
40+
41+
Non-AI: the conversions are closed-form thermodynamics
42+
(Kd = exp(ΔG/RT)) + propagation of σ via the dG-uncertainty.
43+
No learned surrogate.
44+
"""
45+
from __future__ import annotations
46+
47+
import math
48+
import time
49+
from dataclasses import dataclass, field
50+
from typing import Optional
51+
52+
53+
# Boltzmann constant in kcal/mol/K (the unit FEP outputs use).
54+
_KB_KCAL_PER_MOL_K = 0.0019872041
55+
56+
57+
@dataclass
58+
class RateLawPrior:
59+
"""Provenance-tracked rate-law record consumed by Campaign-2.
60+
61+
All numeric fields use SI molar units where applicable so a
62+
Campaign-2 ODE can plug them in without unit fiddling.
63+
"""
64+
type: str # 'hill' or 'michaelis'
65+
parameters: dict # see per-type docs below
66+
parameter_ci95: dict # 2-tuples (lo, hi) per param
67+
source: str # 'FEP' | 'phenomenological' | 'literature'
68+
method: str # e.g. 'amber14-DDM-MBAR'
69+
temperature_K: float
70+
timestamp_iso: str = field(
71+
default_factory=lambda: time.strftime("%Y-%m-%dT%H:%M:%SZ",
72+
time.gmtime()))
73+
notes: str = ""
74+
75+
def summary(self) -> str:
76+
"""One-line summary suitable for Campaign-2 config logs."""
77+
params = ", ".join(
78+
f"{k}={v}" for k, v in self.parameters.items())
79+
return (f"[{self.type}] {params} "
80+
f"src={self.source}/{self.method} "
81+
f"T={self.temperature_K:.1f}K")
82+
83+
84+
def binding_to_hill(
85+
dG_kcalmol: float,
86+
*,
87+
T_K: float = 298.15,
88+
n_hill: float = 1.0,
89+
uncertainty_kcalmol: Optional[float] = None,
90+
method: str = "FEP-DDM-MBAR",
91+
) -> RateLawPrior:
92+
"""Absolute binding ΔG → Hill-equation rate-law prior.
93+
94+
Hill equation:
95+
fraction_bound([L]) = [L]^n / (K_d^n + [L]^n)
96+
97+
where K_d = exp(ΔG / RT). Uncertainty in ΔG (1σ) propagates
98+
multiplicatively to K_d:
99+
σ(ln K_d) = σ(ΔG) / RT
100+
K_d_lo = K_d × exp(-1.96 × σ_ln) (95% CI lower)
101+
K_d_hi = K_d × exp(+1.96 × σ_ln)
102+
103+
Args:
104+
dG_kcalmol: absolute binding free energy. Negative for
105+
binders; sign convention matches `compute_absolute_
106+
binding_dg` output.
107+
T_K: temperature (default 298.15 K — physiological-ish,
108+
matches the FEP sampler default).
109+
n_hill: Hill cooperativity coefficient (default 1.0 —
110+
non-cooperative single-site binding; Campaign-2
111+
allosteric models override).
112+
uncertainty_kcalmol: 1σ uncertainty on ΔG (typically MBAR
113+
σ from the bench CSV's uncertainty_kcalmol column).
114+
None → no CI propagated; the resulting record will
115+
have parameter_ci95 = {} so the consumer knows the
116+
estimate is uncalibrated.
117+
method: provenance string (default 'FEP-DDM-MBAR' for
118+
absolute binding from compute_absolute_binding_dg;
119+
override to 'phenomenological-Lipinski' if the input
120+
came from the OLD/ Tier-0 BindingMatcher).
121+
122+
Returns: RateLawPrior with parameters['Kd_M'] = K_d in M
123+
(NOT mM), parameters['n_hill'] = n_hill.
124+
"""
125+
RT = _KB_KCAL_PER_MOL_K * T_K
126+
Kd_M = math.exp(dG_kcalmol / RT)
127+
128+
parameters = {"Kd_M": Kd_M, "n_hill": float(n_hill)}
129+
parameter_ci95: dict = {}
130+
if uncertainty_kcalmol is not None and uncertainty_kcalmol > 0:
131+
sigma_ln_Kd = uncertainty_kcalmol / RT
132+
Kd_lo = Kd_M * math.exp(-1.96 * sigma_ln_Kd)
133+
Kd_hi = Kd_M * math.exp(+1.96 * sigma_ln_Kd)
134+
parameter_ci95 = {"Kd_M": (Kd_lo, Kd_hi)}
135+
136+
return RateLawPrior(
137+
type="hill",
138+
parameters=parameters,
139+
parameter_ci95=parameter_ci95,
140+
source="FEP" if method.startswith("FEP") else "phenomenological",
141+
method=method,
142+
temperature_K=T_K,
143+
)
144+
145+
146+
def affinity_to_michaelis(
147+
kcat_per_s: float,
148+
KM_M: float,
149+
*,
150+
T_K: float = 298.15,
151+
uncertainty_kcat: Optional[float] = None,
152+
uncertainty_KM: Optional[float] = None,
153+
method: str = "literature",
154+
) -> RateLawPrior:
155+
"""Enzyme kinetics → Michaelis-Menten rate-law prior.
156+
157+
Pass-through for now: FEP doesn't directly produce kcat (that
158+
requires transition-state theory + a reactive sub-tier we
159+
haven't implemented). The function exists so Campaign-2
160+
loaders have a single import surface — when CellSim grows a
161+
reactive-FEP / Eyring TS module, the body changes here and
162+
every downstream consumer benefits.
163+
164+
Args:
165+
kcat_per_s: turnover number in s⁻¹.
166+
KM_M: Michaelis constant in M.
167+
T_K: temperature.
168+
uncertainty_kcat: 1σ on kcat in s⁻¹ (default None → no CI).
169+
uncertainty_KM: 1σ on KM in M (default None → no CI).
170+
method: provenance ('literature' if the values are from
171+
BRENDA / SABIO; 'reactive-FEP' once that ships).
172+
173+
Returns: RateLawPrior with type='michaelis'.
174+
"""
175+
parameters = {"kcat_per_s": kcat_per_s, "KM_M": KM_M}
176+
parameter_ci95: dict = {}
177+
if uncertainty_kcat is not None and uncertainty_kcat > 0:
178+
parameter_ci95["kcat_per_s"] = (
179+
kcat_per_s - 1.96 * uncertainty_kcat,
180+
kcat_per_s + 1.96 * uncertainty_kcat)
181+
if uncertainty_KM is not None and uncertainty_KM > 0:
182+
parameter_ci95["KM_M"] = (
183+
KM_M - 1.96 * uncertainty_KM,
184+
KM_M + 1.96 * uncertainty_KM)
185+
186+
return RateLawPrior(
187+
type="michaelis",
188+
parameters=parameters,
189+
parameter_ci95=parameter_ci95,
190+
source="FEP" if method.startswith("reactive-FEP") else
191+
"literature" if method == "literature" else
192+
"phenomenological",
193+
method=method,
194+
temperature_K=T_K,
195+
)
196+
197+
198+
__all__ = [
199+
"RateLawPrior",
200+
"binding_to_hill",
201+
"affinity_to_michaelis",
202+
]

tests/bridge/__init__.py

Whitespace-only changes.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""src/bridge — Campaign-1 → Campaign-2 rate-law emitter smoke.
2+
3+
Pin the closed-form thermodynamic conversions so a future
4+
refactor of the Hill / Michaelis primitives can't silently
5+
drift the K_d / CI numbers Campaign-2 will consume.
6+
"""
7+
from __future__ import annotations
8+
9+
import math
10+
import sys
11+
from pathlib import Path
12+
13+
REPO_ROOT = Path(__file__).resolve().parents[2]
14+
sys.path.insert(0, str(REPO_ROOT))
15+
16+
from src.bridge import ( # noqa: E402
17+
RateLawPrior,
18+
binding_to_hill,
19+
affinity_to_michaelis,
20+
)
21+
22+
23+
def test_binding_to_hill_kd_matches_closed_form():
24+
"""ΔG = -10 kcal/mol at 298.15 K should give K_d ≈ 50 nM
25+
(the canonical 'good drug' affinity)."""
26+
prior = binding_to_hill(-10.0)
27+
Kd_M = prior.parameters["Kd_M"]
28+
# K_d = exp(-10 / (R*T)) at T=298.15
29+
expected = math.exp(-10.0 / (0.0019872041 * 298.15))
30+
assert abs(Kd_M - expected) < 1e-15
31+
# Sanity: ~50 nM (5e-8 M)
32+
assert 1e-8 < Kd_M < 1e-7, f"K_d {Kd_M} M not in nM range"
33+
34+
35+
def test_binding_to_hill_uncertainty_propagates_multiplicatively():
36+
"""1σ on ΔG → multiplicative CI on K_d (since K_d is
37+
exponential in ΔG)."""
38+
prior = binding_to_hill(-10.0, uncertainty_kcalmol=0.5)
39+
Kd_M = prior.parameters["Kd_M"]
40+
Kd_lo, Kd_hi = prior.parameter_ci95["Kd_M"]
41+
42+
# 95% CI is ±1.96σ in ΔG → exp(±1.96σ/RT) in K_d.
43+
sigma_ln = 0.5 / (0.0019872041 * 298.15)
44+
expected_lo = Kd_M * math.exp(-1.96 * sigma_ln)
45+
expected_hi = Kd_M * math.exp(+1.96 * sigma_ln)
46+
assert abs(Kd_lo - expected_lo) / expected_lo < 1e-12
47+
assert abs(Kd_hi - expected_hi) / expected_hi < 1e-12
48+
49+
# CI must be asymmetric on a multiplicative scale.
50+
# log10(Kd_hi/Kd_M) should equal log10(Kd_M/Kd_lo).
51+
geom_lo = math.log10(Kd_M / Kd_lo)
52+
geom_hi = math.log10(Kd_hi / Kd_M)
53+
assert abs(geom_lo - geom_hi) < 1e-12
54+
55+
56+
def test_binding_to_hill_no_uncertainty_no_ci():
57+
"""No σ → no CI populated. Campaign-2 consumer can branch
58+
on `bool(prior.parameter_ci95)` to know if estimate is
59+
calibrated."""
60+
prior = binding_to_hill(-10.0)
61+
assert prior.parameter_ci95 == {}
62+
assert prior.source == "FEP" # default method starts with 'FEP'
63+
assert prior.method == "FEP-DDM-MBAR"
64+
65+
66+
def test_binding_to_hill_phenomenological_source_tag():
67+
"""When the input came from the OLD/ Tier-0 BindingMatcher
68+
(descriptor-fit), the consumer needs to know the prior is
69+
NOT physics-grounded — propagate that via source='phenomenological'."""
70+
prior = binding_to_hill(
71+
-7.0, method="phenomenological-Lipinski",
72+
uncertainty_kcalmol=2.5) # inflated σ for the descriptor fit
73+
assert prior.source == "phenomenological"
74+
assert "phenomenological" in prior.method
75+
76+
77+
def test_affinity_to_michaelis_passes_through():
78+
"""Pass-through impl for now. Just verify the dataclass shape
79+
is sane so Campaign-2 loaders can unpack consistently."""
80+
prior = affinity_to_michaelis(
81+
kcat_per_s=100.0, KM_M=1e-5,
82+
uncertainty_kcat=10.0, uncertainty_KM=2e-6)
83+
assert prior.type == "michaelis"
84+
assert prior.parameters == {"kcat_per_s": 100.0, "KM_M": 1e-5}
85+
assert "kcat_per_s" in prior.parameter_ci95
86+
assert "KM_M" in prior.parameter_ci95
87+
# Default method is 'literature'
88+
assert prior.source == "literature"
89+
90+
91+
def test_summary_one_line():
92+
prior = binding_to_hill(-12.0, uncertainty_kcalmol=0.4)
93+
s = prior.summary()
94+
assert s.startswith("[hill]")
95+
assert "src=FEP" in s
96+
assert "T=298.1K" in s
97+
98+
99+
if __name__ == "__main__":
100+
funcs = [
101+
test_binding_to_hill_kd_matches_closed_form,
102+
test_binding_to_hill_uncertainty_propagates_multiplicatively,
103+
test_binding_to_hill_no_uncertainty_no_ci,
104+
test_binding_to_hill_phenomenological_source_tag,
105+
test_affinity_to_michaelis_passes_through,
106+
test_summary_one_line,
107+
]
108+
fails = []
109+
for f in funcs:
110+
try:
111+
f()
112+
print(f"[PASS] {f.__name__}")
113+
except AssertionError as e:
114+
print(f"[FAIL] {f.__name__}: {e}")
115+
fails.append(f.__name__)
116+
except Exception as e:
117+
import traceback
118+
traceback.print_exc()
119+
print(f"[ERROR] {f.__name__}: {e}")
120+
fails.append(f.__name__)
121+
print(f"{len(funcs) - len(fails)}/{len(funcs)} PASS")
122+
sys.exit(0 if not fails else 1)

0 commit comments

Comments
 (0)