|
| 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