11#!/usr/bin/env python3
22"""Fill the professor-debrief email template with numbers from a
3- fep-report run.
3+ fep-report run — hydration (Milestone A / FreeSolv) OR binding
4+ (Milestone B / streptavidin / EGFR).
45
56Usage:
67 python scripts/fill_prof_email.py run/fep/verdict/
78
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.
9+ The script auto-detects whether the report is hydration or binding
10+ by reading the first-line markdown title (# Hydration FEP report ...
11+ vs # Binding FEP report ...) and selects the matching email
12+ template.
13+
14+ From the report.md it parses:
15+ Hydration-specific:
16+ - methane + acetamide sign-correctness (Milestone A critical pair)
17+ Binding-specific:
18+ - per-compound sign-correctness (every ΔG_bind must be < 0)
19+ Both:
20+ - MAE, RMSE, Pearson r, Spearman ρ, Kendall τ
21+ - GHMC mean + worst-window
22+ - overall verdict (PASS / FAIL / partial / inconclusive)
23+ - n/m compound count
24+
25+ Emits the filled email body on stdout (or --out file). The
26+ parity.png + table.csv you attach are whatever's in the same dir.
27+
28+ Exit 0 if all fields filled; 2 if any <missing> remains so you
29+ don't accidentally send placeholder markers to the prof.
2330"""
2431from __future__ import annotations
2532
2936from pathlib import Path
3037
3138
32- EMAIL_TEMPLATE = """\
39+ HYDRATION_TEMPLATE = """\
3340 Subject: Milestone A — FreeSolv FEP results
3441
3542Hi {prof_name},
6067== Headline numbers ==
6168
6269- Overall verdict: {overall_verdict}
70+ - Compounds completed: {n_ok}/{n_total}
6371- MAE vs FreeSolv published values: {mae:>5} kcal/mol (gate <= 1.5)
6472- Pearson r: {pearson:>5}
6573- Spearman rho: {spearman:>5}
94102 is enforced per-compound: any window < 70% forces the report to
95103 refuse PASS. Full per-window vector is in the tarball's run.log.
961044. Reproducible from fresh clone. environment.yml is pinned,
97- `cellsim doctor` runs 42 install + benchmark checks, 54 smoke
105+ `cellsim doctor` runs 42 install + benchmark checks, 54+ smoke
98106 tests gate every code change. The M5 Max ran the same script my
99107 CI runs.
100108
130138"""
131139
132140
141+ BINDING_TEMPLATE = """\
142+ Subject: Milestone B — {subject_tag} FEP results
143+
144+ Hi {prof_name},
145+
146+ Milestone B Phase-2 ran on {bench_name}. Results below.
147+
148+ == What ran ==
149+
150+ - Hardware: {hardware_hint} (OpenMM {platform_hint} backend)
151+ - Force field: AMBER14 (protein + tip3pfb water) + OpenFF Sage 2.1.0
152+ + AM1-BCC charges (ligand) via openmmforcefields'
153+ SMIRNOFFTemplateGenerator — no learned surrogate at any layer
154+ - Method: double-decoupling (DDM) absolute binding ΔG
155+ ΔG_bind = −(ΔG_decouple_complex − ΔG_decouple_solvent)
156+ + ΔG_restraint_correction (Hamelberg-Gilson analytical)
157+ - MD: openmmtools.alchemy + GHMC integrator, 1 fs timestep,
158+ 11 lambda-windows per leg × 2 legs (complex + solvent)
159+ - Estimator: pymbar 4.2.0 MBAR
160+ - Per compound: 50 ps equilibration + 50 ps production per window
161+ ~= 2.2 ns simulated MD per compound
162+ - Compounds: {n_total_compounds} ({bench_name} series)
163+
164+ == Headline numbers ==
165+
166+ - Overall verdict: {overall_verdict}
167+ - Compounds completed: {n_ok}/{n_total}
168+ - MAE vs published ΔG_bind: {mae:>5} kcal/mol (gate <= 2.0)
169+ - Pearson r: {pearson:>5}
170+ - Spearman rho: {spearman:>5}
171+ - Kendall tau: {kendall:>5} (rank-correlation gate: >= 0.6)
172+ - All compounds predicted as binders: {binding_sign}
173+ - GHMC acceptance: mean {ghmc_mean}, worst {ghmc_worst} (gate >= 70%)
174+
175+ == Parity figure ==
176+
177+ Attached parity.png — predicted vs experimental ΔG_bind, ±2.0 kcal/mol
178+ gate band shaded, per-point error bars from MBAR. Compound labels
179+ adjacent to each point.
180+
181+ == What this answers ==
182+
183+ This is the test that retires your 'physics-FEP vs Vina on kinases'
184+ critique. The same {bench_name} chemical series where Vina's
185+ empirical scoring gave Spearman −0.49 (anti-correlated with
186+ experiment) — this FEP run reports Spearman {spearman} and Kendall
187+ {kendall} against the same published reference ΔG values.
188+
189+ The absolute-ΔG MAE carries a Cheng-Prusoff offset (we used ΔG = RT
190+ ln(IC50) since papers rarely report the [ATP]_Km pairs needed for the
191+ correction). That offset is constant across the series, so the rank-
192+ correlation (Kendall τ) is the load-bearing metric; absolute MAE is
193+ informative but offset-limited.
194+
195+ == What this does NOT yet address ==
196+
197+ 1. Protein-specific force-field transferability. ff14SB covers
198+ standard amino acids; non-standard residues / cofactors / metal
199+ sites need separate parametrisation.
200+ 2. Slow conformational changes. 50 ps/window doesn't capture
201+ rearrangements > 100 ns timescale (rare-event activation loops,
202+ large domain motions).
203+ 3. Campaign-2 cell-level numbers. Per your gate, those start after
204+ Milestone B clears.
205+
206+ == Proposed next ==
207+
208+ {proposed_next}
209+
210+ Tarball, report.md, and parity.png attached.
211+
212+ — Henry
213+ """
214+
215+
133216def _grab_float (text : str , label : str ) -> str | None :
134217 """Find a number after a label like 'MAE = 0.420' or
135218 'Pearson r = +0.993'. Returns the number as a string or
@@ -155,47 +238,100 @@ def _grab_ghmc(text: str) -> tuple[str | None, str | None]:
155238
156239
157240def _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- """
241+ """Hydration-specific: check sign of methane/acetamide rows."""
167242 pattern = rf"\|\s*{ re .escape (name )} \s*\|[^|]*\|\s*([+-][\d.]+)\s*\|\s*([+-][\d.]+)\s*\|"
168243 m = re .search (pattern , text )
169244 if not m :
170245 return None
171246 expt , pred = m .groups ()
172247 expt_v = float (expt )
173248 pred_v = float (pred )
174- # Match analyser's near-zero rule: |expt| < 0.3 → either sign ok.
175249 if abs (expt_v ) < 0.3 :
176250 return "PASS"
177251 same_sign = (expt_v >= 0 ) == (pred_v >= 0 )
178252 return "PASS" if same_sign else "FAIL"
179253
180254
255+ def _grab_binding_all_negative (text : str ) -> str | None :
256+ """Binding-specific: every predicted ΔG_bind must be < 0.
257+ Scrape the per-compound table and check the 'pred' column
258+ for all rows. Scaffolded/failed rows are ignored — they get
259+ 'scaffolded' or 'FAIL' in the pred cell and don't count."""
260+ rows = re .findall (
261+ r"\|\s*(\S+)\s*\|\s*`[^`]*`\s*\|\s*"
262+ r"[+-]?[\d.]+\s*\|\s*([+-][\d.]+)\s*\|" ,
263+ text )
264+ if not rows :
265+ return None
266+ non_binders = [n for n , pred in rows if float (pred ) >= 0 ]
267+ if non_binders :
268+ return f"FAIL ({ ', ' .join (non_binders )} predicted non-binder)"
269+ return "PASS"
270+
271+
181272def _grab_overall_verdict (text : str ) -> str :
182273 """First-line header: '# Hydration FEP report — PASS' (or FAIL,
183274 inconclusive, partial)."""
184275 m = re .search (r"^#\s+\S.*?—\s*(.+)$" , text , re .MULTILINE )
185276 return m .group (1 ).strip () if m else "(unknown)"
186277
187278
279+ def _detect_yaml_kind (text : str ) -> str :
280+ """From the markdown title '# Hydration FEP report' or
281+ '# Binding FEP report'. Default 'hydration'."""
282+ m = re .search (r"^#\s+(Hydration|Binding)\s+FEP report" ,
283+ text , re .MULTILINE )
284+ if m :
285+ return m .group (1 ).lower ()
286+ return "hydration"
287+
288+
289+ def _grab_counts (text : str ) -> tuple [str , str ]:
290+ """From the 'compounds: N ok / M total' line, return
291+ (n_ok, n_total) as strings."""
292+ m = re .search (
293+ r"compounds:\s+(\d+)\s+ok\s*/\s*(\d+)\s+total" , text )
294+ if m :
295+ return m .group (1 ), m .group (2 )
296+ return "?" , "?"
297+
298+
299+ def _infer_bench_name (text : str ) -> str :
300+ """For binding: try to pick a human-readable bench name from
301+ rows present. Fallback: generic 'binding'."""
302+ rows = [m .group (1 ) for m in re .finditer (
303+ r"^\|\s*(\S+)\s*\|\s*`[^`]*`\s*\|" , text , re .MULTILINE )]
304+ row_names = {r .lower () for r in rows }
305+ # Streptavidin markers
306+ if {"biotin" , "desthiobiotin" } & row_names :
307+ return "streptavidin"
308+ if {"erlotinib" , "gefitinib" , "ag-1478" , "lapatinib" } & row_names :
309+ return "EGFR kinase"
310+ return "binding"
311+
312+
188313def main (argv : list [str ] | None = None ) -> int :
189314 ap = argparse .ArgumentParser (
190315 description = "Fill the prof-debrief email from a fep-report "
191- "directory's report.md" )
316+ "directory's report.md. Auto-detects hydration "
317+ "vs binding from the report title." )
192318 ap .add_argument (
193319 "report_dir" ,
194320 help = "path to the run/fep/verdict/ directory (or any dir "
195321 "containing report.md)" )
196322 ap .add_argument (
197323 "--prof-name" , default = "[Prof]" ,
198324 help = "name to address (default '[Prof]' — fill in by hand)" )
325+ ap .add_argument (
326+ "--hardware" , default = "rented GPU" ,
327+ help = "binding-template hardware hint (default 'rented GPU'; "
328+ "override with 'Apple M5 Max (40-core GPU)' etc.)" )
329+ ap .add_argument (
330+ "--platform" , default = "CUDA" ,
331+ help = "binding-template platform hint (CUDA | Metal | OpenCL)" )
332+ ap .add_argument (
333+ "--next-step" , default = None ,
334+ help = "binding-template 'Proposed next' paragraph override" )
199335 ap .add_argument (
200336 "--out" , default = "-" ,
201337 help = "write the filled email to this file (default '-' = stdout)" )
@@ -208,39 +344,80 @@ def main(argv: list[str] | None = None) -> int:
208344 return 1
209345
210346 md = report_path .read_text (encoding = "utf-8" )
347+ kind = _detect_yaml_kind (md )
211348
212349 overall = _grab_overall_verdict (md )
350+ n_ok , n_total = _grab_counts (md )
213351 mae = _grab_float (md , "MAE" ) or "<missing>"
214352 pearson = _grab_float (md , "Pearson r" ) or "<missing>"
215353 spearman = _grab_float (md , "Spearman ρ" ) or "<missing>"
216354 kendall = _grab_float (md , "Kendall τ" ) or "<missing>"
217355 ghmc_mean , ghmc_worst = _grab_ghmc (md )
218356 ghmc_mean = ghmc_mean or "<missing>"
219357 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- )
358+
359+ if kind == "binding" :
360+ bench_name = _infer_bench_name (md )
361+ # Subject line reads cleaner with a named bench vs the
362+ # fallback. 'streptavidin' → 'Subject: Milestone B —
363+ # streptavidin binding FEP', 'binding' → 'Subject: Milestone
364+ # B — binding FEP'.
365+ if bench_name == "binding" :
366+ subject_tag = "binding"
367+ else :
368+ subject_tag = f"{ bench_name } binding"
369+ binding_sign = _grab_binding_all_negative (md ) or "<missing>"
370+ proposed = args .next_step or (
371+ "If Kendall τ >= 0.6 AND all compounds predicted as "
372+ "binders: the EGFR rank-order rescue claim is evidenced "
373+ "— happy to discuss Campaign-2 sequencing next.\n \n "
374+ "If τ < 0.6 or any non-binder prediction: we'd identify "
375+ "the problem compound(s) and extend sampling / inspect "
376+ "pose before trusting downstream." )
377+ filled = BINDING_TEMPLATE .format (
378+ prof_name = args .prof_name ,
379+ bench_name = bench_name ,
380+ subject_tag = subject_tag ,
381+ hardware_hint = args .hardware ,
382+ platform_hint = args .platform ,
383+ n_total_compounds = n_total ,
384+ n_ok = n_ok ,
385+ n_total = n_total ,
386+ overall_verdict = overall ,
387+ mae = mae ,
388+ pearson = pearson ,
389+ spearman = spearman ,
390+ kendall = kendall ,
391+ binding_sign = binding_sign ,
392+ ghmc_mean = ghmc_mean ,
393+ ghmc_worst = ghmc_worst ,
394+ proposed_next = proposed ,
395+ )
396+ else :
397+ # Hydration template (Milestone A)
398+ methane_sign = _grab_compound_sign (md , "methane" ) or "<missing>"
399+ acetamide_sign = _grab_compound_sign (md , "acetamide" ) or "<missing>"
400+ filled = HYDRATION_TEMPLATE .format (
401+ prof_name = args .prof_name ,
402+ overall_verdict = overall ,
403+ n_ok = n_ok ,
404+ n_total = n_total ,
405+ mae = mae ,
406+ pearson = pearson ,
407+ spearman = spearman ,
408+ kendall = kendall ,
409+ ghmc_mean = ghmc_mean ,
410+ ghmc_worst = ghmc_worst ,
411+ methane_sign = methane_sign ,
412+ acetamide_sign = acetamide_sign ,
413+ )
235414
236415 if args .out == "-" :
237416 print (filled )
238417 else :
239418 Path (args .out ).write_text (filled , encoding = "utf-8" )
240419 print (f"wrote { args .out } " , file = sys .stderr )
241420
242- # Refuse to silently succeed if any field is still missing — the
243- # biologist might paste the result without noticing.
244421 if "<missing>" in filled :
245422 print ("\n fill_prof_email: WARNING — some fields are missing "
246423 "(see <missing> markers above). Inspect report.md "
0 commit comments