Skip to content

Commit 4c76625

Browse files
committed
scripts: Phase-2 GPU runners + prof-email auto-fill
Three artifacts ready for the moment Milestone A clears: scripts/run_binding_streptavidin_gpu.sh Drops onto a rented H100 / Apple-silicon Mac, runs the 4-cpd binding_streptavidin.yaml at production parameters (11 wins × 25 000 prod + 2 500 equil × 2 legs per compound), invokes cellsim fep-report inline, bundles into streptavidin_gpu_<stamp> .tar.gz with the verdict + parity PNG baked in. --resume on the bench so power loss doesn't lose work. Mirrors the existing run_freesolv_m5max.sh pattern with the binding-specific gate (2.0 kcal/mol MAE + every-ΔG-negative sign rule). scripts/run_binding_egfr_gpu.sh Sibling for the EGFR 6-compound series — the one where Vina got Spearman -0.49. This is the headline test that retires the prof's 'physics-FEP vs Vina on kinases' critique. scripts/fill_prof_email.py When the friend's tarball lands and you've run cellsim fep-report --out-dir run/fep/verdict/, this script reads report.md and emits the prof debrief email with all numbers filled (MAE, Pearson, Spearman, Kendall, methane sign, acetamide sign, GHMC mean + worst, overall verdict). Refuses to silently succeed if any field is missing (exits 2 with a clear warning) so you don't paste an email full of <missing> markers. Smoke-tested on the ok_case fixture: all 8 numeric fields filled correctly. Now the Milestone-A → Milestone-B handoff is one command per step: 1. cellsim fep-report friend_tarball.tar.gz --yaml benchmarks/fep/freesolv_12.yaml --out-dir run/fep/verdict/ 2. python scripts/fill_prof_email.py run/fep/verdict/ > prof_email.txt 3. (paste prof_email.txt + attach parity.png; send) 4. (when prof OKs GPU spend) bash scripts/run_binding_streptavidin_gpu.sh 5. bash scripts/run_binding_egfr_gpu.sh
1 parent e27d887 commit 4c76625

3 files changed

Lines changed: 596 additions & 0 deletions

File tree

scripts/fill_prof_email.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#!/usr/bin/env python3
2+
"""Fill the professor-debrief email template with numbers from a
3+
fep-report run.
4+
5+
Usage:
6+
python scripts/fill_prof_email.py run/fep/verdict/
7+
8+
Reads `report.md` from the supplied directory, parses out:
9+
- MAE, RMSE, Pearson r, Spearman ρ, Kendall τ from the
10+
'Aggregate accuracy' section
11+
- methane + acetamide rows from the per-compound table → sign
12+
PASS/FAIL
13+
- GHMC mean + worst from the gate verdict line
14+
- overall verdict (PASS / FAIL / partial / inconclusive)
15+
16+
Emits the email body on stdout, ready to copy into your message.
17+
The `parity.png` you attach is whatever's already in the same
18+
directory (the script doesn't move files).
19+
20+
Exit 0 if the email could be filled. Exit 1 if report.md is
21+
missing or unparseable so you don't accidentally send an email
22+
full of <X.XX> placeholders.
23+
"""
24+
from __future__ import annotations
25+
26+
import argparse
27+
import re
28+
import sys
29+
from pathlib import Path
30+
31+
32+
EMAIL_TEMPLATE = """\
33+
Subject: Milestone A — FreeSolv FEP results
34+
35+
Hi {prof_name},
36+
37+
Quick update on Milestone A. The CellSim Layer-1.3 alchemical FEP
38+
pipeline is now end-to-end on a Mac and the FreeSolv-12 hydration
39+
gate ran on a friend's M5 Max overnight. Results below.
40+
41+
== What ran ==
42+
43+
- Hardware: Apple M5 Max (40-core GPU), OpenMM Metal backend
44+
- Force field: OpenFF Sage 2.1.0 (small molecules) + AM1-BCC partial
45+
charges + TIP3P water — no learned surrogate at any layer
46+
- MD: openmmtools.alchemy + GHMC integrator (Metropolised Langevin),
47+
1 fs timestep, 11 lambda-windows per leg
48+
- Estimator: pymbar 4.2.0 MBAR (closed-form from alchemical samples)
49+
- Per compound: 50 ps equilibration + 50 ps production per window
50+
x 11 windows x 2 legs (vacuum + TIP3P-solvated decoupling)
51+
~= 2.2 ns simulated MD; 12 compounds ~= 26 ns total
52+
- Compounds: the FreeSolv subset I sent before — methane (+2.00) ->
53+
acetamide (-9.71) kcal/mol experimental, spans the dynamic range
54+
we care about
55+
- Software pinned in environment.yml (rdkit 2025.9.5, openmm 8.5.1,
56+
openmmtools 0.26.0, openff-toolkit 0.18.0, pymbar 4.2.0). No perses
57+
dependency — openmmtools primitives + a custom DDM driver covered
58+
the scope.
59+
60+
== Headline numbers ==
61+
62+
- Overall verdict: {overall_verdict}
63+
- MAE vs FreeSolv published values: {mae:>5} kcal/mol (gate <= 1.5)
64+
- Pearson r: {pearson:>5}
65+
- Spearman rho: {spearman:>5}
66+
- Kendall tau: {kendall:>5}
67+
- Sign-correct on methane: {methane_sign}
68+
- Sign-correct on acetamide: {acetamide_sign}
69+
- GHMC acceptance: mean {ghmc_mean}, worst {ghmc_worst} (gate >= 70%)
70+
71+
== Parity figure ==
72+
73+
Attached parity.png — predicted vs experimental ΔG_hyd, ±1.5 kcal/mol
74+
gate band shaded, per-point error bars from MBAR, all 12 compounds
75+
labelled.
76+
77+
== What this answers from your last critique ==
78+
79+
You raised closed-ontology / circular validation: that CellSim's
80+
binding affinities were hand-tuned phenomenology dressed as physics,
81+
so any cell-level prediction built on top would inherit the
82+
unfalsifiable layer underneath. I've closed that gap on the
83+
small-molecule axis:
84+
85+
1. Physics-derived priors with calibrated uncertainty. Every ΔG
86+
comes from MBAR over alchemical samples; σ from MBAR. No
87+
empirical scoring function anywhere. The per-compound report has
88+
a "within σ" column saying which residuals are statistically
89+
meaningful vs sampling noise.
90+
2. Sign-correctness as a hard gate. Methane and acetamide signs are
91+
explicit gate items — if either flips, the report refuses PASS
92+
regardless of MAE.
93+
3. GHMC acceptance gate. Your "monitor the acceptance" requirement
94+
is enforced per-compound: any window < 70% forces the report to
95+
refuse PASS. Full per-window vector is in the tarball's run.log.
96+
4. Reproducible from fresh clone. environment.yml is pinned,
97+
`cellsim doctor` runs 42 install + benchmark checks, 54 smoke
98+
tests gate every code change. The M5 Max ran the same script my
99+
CI runs.
100+
101+
== What this does NOT yet address ==
102+
103+
1. Binding ΔG / ΔΔG on real receptors. The Milestone B pipeline
104+
(double-decoupling, amber14 + SMIRNOFF-ligand, harmonic CoM
105+
restraint, analytical Hamelberg-Gilson correction) is built and
106+
tested end-to-end on an integration smoke (1ubq + methane,
107+
82.5 s CPU). No GPU-sampled numbers on real binders yet.
108+
2. EGFR rank-order rescue. The same 6-compound EGFR series where
109+
Vina gave Spearman -0.49 is validated + ready to sample
110+
(benchmarks/fep/binding_egfr.yaml). Awaiting GPU time.
111+
3. Campaign-2 work. Per your gate, the cell-level rebuild does not
112+
start until Milestone A clears.
113+
114+
== Proposed next ==
115+
116+
If Milestone A clears: streptavidin-biotin reference set (4
117+
compounds, ~15 min GPU/compound estimated) followed by the EGFR
118+
6-compound series (~38 min total). Looking for your sign-off on
119+
cloud-GPU spend before booking the run.
120+
121+
If Milestone A fails on methane sign specifically: I'd extend that
122+
single compound to 200 ps/window and re-test before touching Phase 2.
123+
124+
If MAE is systematically > 2.5: open conversation — switch to ff19SB
125+
/ longer windows / different tip3p variant.
126+
127+
Tarball, report.md, and parity.png attached.
128+
129+
— Henry
130+
"""
131+
132+
133+
def _grab_float(text: str, label: str) -> str | None:
134+
"""Find a number after a label like 'MAE = 0.420' or
135+
'Pearson r = +0.993'. Returns the number as a string or
136+
None."""
137+
pattern = rf"{re.escape(label)}\s*=\s*([+-]?[\d.]+)"
138+
m = re.search(pattern, text)
139+
return m.group(1) if m else None
140+
141+
142+
def _grab_ghmc(text: str) -> tuple[str | None, str | None]:
143+
"""From the gate verdict line, e.g.:
144+
'overall mean 77%, worst 71%, 3 compounds report'
145+
return (mean, worst) as strings with %."""
146+
m = re.search(
147+
r"overall mean\s+([\d.]+%?),\s+worst\s+([\d.]+%?)", text)
148+
if not m:
149+
return None, None
150+
mean, worst = m.groups()
151+
return (
152+
mean if mean.endswith("%") else mean + "%",
153+
worst if worst.endswith("%") else worst + "%",
154+
)
155+
156+
157+
def _grab_compound_sign(text: str, name: str) -> str | None:
158+
"""In the per-compound table, find the row for `name` and
159+
return 'PASS' if pred and expt have the same sign, 'FAIL'
160+
otherwise. Looks for 'SIGN WRONG' marker first (analyser's
161+
direct flag) then falls back to inspecting the +/− on pred
162+
vs expt cols.
163+
164+
Table row shape:
165+
| acetamide | `CC(=O)N` | -9.71 | -8.90 | 0.55 | +0.81 | 0.81 | ... |
166+
"""
167+
pattern = rf"\|\s*{re.escape(name)}\s*\|[^|]*\|\s*([+-][\d.]+)\s*\|\s*([+-][\d.]+)\s*\|"
168+
m = re.search(pattern, text)
169+
if not m:
170+
return None
171+
expt, pred = m.groups()
172+
expt_v = float(expt)
173+
pred_v = float(pred)
174+
# Match analyser's near-zero rule: |expt| < 0.3 → either sign ok.
175+
if abs(expt_v) < 0.3:
176+
return "PASS"
177+
same_sign = (expt_v >= 0) == (pred_v >= 0)
178+
return "PASS" if same_sign else "FAIL"
179+
180+
181+
def _grab_overall_verdict(text: str) -> str:
182+
"""First-line header: '# Hydration FEP report — PASS' (or FAIL,
183+
inconclusive, partial)."""
184+
m = re.search(r"^#\s+\S.*?—\s*(.+)$", text, re.MULTILINE)
185+
return m.group(1).strip() if m else "(unknown)"
186+
187+
188+
def main(argv: list[str] | None = None) -> int:
189+
ap = argparse.ArgumentParser(
190+
description="Fill the prof-debrief email from a fep-report "
191+
"directory's report.md")
192+
ap.add_argument(
193+
"report_dir",
194+
help="path to the run/fep/verdict/ directory (or any dir "
195+
"containing report.md)")
196+
ap.add_argument(
197+
"--prof-name", default="[Prof]",
198+
help="name to address (default '[Prof]' — fill in by hand)")
199+
ap.add_argument(
200+
"--out", default="-",
201+
help="write the filled email to this file (default '-' = stdout)")
202+
args = ap.parse_args(argv)
203+
204+
report_path = Path(args.report_dir) / "report.md"
205+
if not report_path.is_file():
206+
print(f"fill_prof_email: no report.md under {args.report_dir}",
207+
file=sys.stderr)
208+
return 1
209+
210+
md = report_path.read_text(encoding="utf-8")
211+
212+
overall = _grab_overall_verdict(md)
213+
mae = _grab_float(md, "MAE") or "<missing>"
214+
pearson = _grab_float(md, "Pearson r") or "<missing>"
215+
spearman = _grab_float(md, "Spearman ρ") or "<missing>"
216+
kendall = _grab_float(md, "Kendall τ") or "<missing>"
217+
ghmc_mean, ghmc_worst = _grab_ghmc(md)
218+
ghmc_mean = ghmc_mean or "<missing>"
219+
ghmc_worst = ghmc_worst or "<missing>"
220+
methane_sign = _grab_compound_sign(md, "methane") or "<missing>"
221+
acetamide_sign = _grab_compound_sign(md, "acetamide") or "<missing>"
222+
223+
filled = EMAIL_TEMPLATE.format(
224+
prof_name=args.prof_name,
225+
overall_verdict=overall,
226+
mae=mae,
227+
pearson=pearson,
228+
spearman=spearman,
229+
kendall=kendall,
230+
ghmc_mean=ghmc_mean,
231+
ghmc_worst=ghmc_worst,
232+
methane_sign=methane_sign,
233+
acetamide_sign=acetamide_sign,
234+
)
235+
236+
if args.out == "-":
237+
print(filled)
238+
else:
239+
Path(args.out).write_text(filled, encoding="utf-8")
240+
print(f"wrote {args.out}", file=sys.stderr)
241+
242+
# Refuse to silently succeed if any field is still missing — the
243+
# biologist might paste the result without noticing.
244+
if "<missing>" in filled:
245+
print("\nfill_prof_email: WARNING — some fields are missing "
246+
"(see <missing> markers above). Inspect report.md "
247+
"manually.", file=sys.stderr)
248+
return 2
249+
return 0
250+
251+
252+
if __name__ == "__main__":
253+
sys.exit(main())

0 commit comments

Comments
 (0)