Skip to content

Commit a48ce35

Browse files
committed
fep-freesolv: --resume + incremental CSV write so multi-day CPU runs survive crashes
Previously freesolv_validate wrote the CSV only at the end of the whole bench, so a 30-h CPU run dying mid-loop (lid close, kernel panic, power) lost every completed compound. The friend's pilot-1 lost 3 compounds the same way on the M5 Max. Changes: - Incremental atomic CSV write after each compound completes (write to .tmp sibling, then rename). Idempotent with the final summary write. - --resume flag: read existing CSV, skip compounds with non- empty dG_pred_kcalmol. Compounds that were attempted but didn't complete (pred=None / blank) re-run cleanly. - run_freesolv_m5max.sh passes --resume by default; honors OUT_DIR env override so resuming a crashed run is: OUT_DIR=run/fep/20260523_100401 bash scripts/run_freesolv_m5max.sh Switches `tee` to `tee -a` so resume doesn't truncate the log. New runs without OUT_DIR still get a fresh timestamped dir; --resume on an empty CSV is a no-op. Regression: 7/7 covering round-trip, mid-compound-crash exclusion, missing-file, atomic-tmp-rename, schema column pinning, partial- run realistic case, fep-report compatibility. This is precisely the lid-close protection I offered to ship at the start of the laptop CPU run; current in-flight run (PID 91412, started before this commit) doesn't benefit, but ANY rerun from the same OUT_DIR will pick up where the prior run died.
1 parent fa273f7 commit a48ce35

4 files changed

Lines changed: 311 additions & 19 deletions

File tree

.github/workflows/smoke.yml

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

193+
- name: fep-freesolv --resume + incremental CSV (7/7)
194+
run: python -u tests/fep/test_freesolv_resume_smoke.py
195+
193196
- name: bridge — Campaign-1 → Campaign-2 rate-law emitter
194197
run: python -u tests/bridge/test_binding_to_hill_smoke.py
195198

scripts/run_freesolv_m5max.sh

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ fi
3434
source "$(conda info --base)/etc/profile.d/conda.sh"
3535
conda activate cellsim
3636

37-
# Output dir, timestamped so repeat runs don't collide
38-
STAMP=$(date +%Y%m%d_%H%M%S)
39-
OUT_DIR="run/fep/${STAMP}"
37+
# Output dir, timestamped so repeat runs don't collide. Override
38+
# via env: `OUT_DIR=run/fep/20260523_100401 bash <this>` resumes
39+
# a crashed run from where it left off (any compound with a
40+
# non-empty dG_pred_kcalmol in freesolv_results.csv is skipped).
41+
if [ -z "${OUT_DIR}" ]; then
42+
STAMP=$(date +%Y%m%d_%H%M%S)
43+
OUT_DIR="run/fep/${STAMP}"
44+
fi
4045
mkdir -p "${OUT_DIR}"
46+
if [ -f "${OUT_DIR}/freesolv_results.csv" ]; then
47+
echo "[run_freesolv] resuming from existing CSV in ${OUT_DIR}"
48+
fi
4149

4250
# Header block — mirror to env.log so `cellsim fep-report` can
4351
# extract the `git commit:` line for provenance. Earlier versions
@@ -103,7 +111,8 @@ time ./scripts/cellsim fep-freesolv \
103111
--equilibration-steps 5000 \
104112
--sample-stride 250 \
105113
--out-csv "${OUT_DIR}/freesolv_results.csv" \
106-
2>&1 | tee "${OUT_DIR}/run.log"
114+
--resume \
115+
2>&1 | tee -a "${OUT_DIR}/run.log"
107116

108117
EXIT_CODE=${PIPESTATUS[0]}
109118

src/fep/freesolv_validate.py

Lines changed: 120 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,73 @@ def as_dict(self) -> dict:
9898
return asdict(self)
9999

100100

101+
_CSV_COLS = [
102+
"name", "smiles", "dG_expt_kcalmol",
103+
"dG_pred_kcalmol", "uncertainty_kcalmol",
104+
"residual_kcalmol", "ghmc_accept_mean",
105+
"ghmc_accept_min", "wall_seconds", "ok", "reason",
106+
]
107+
108+
109+
def _write_csv(csv_path: Path, entries: list) -> None:
110+
"""Atomic CSV write: write to a .tmp sibling, then rename. Avoids
111+
leaving a half-written file if the process is killed mid-write."""
112+
csv_path.parent.mkdir(parents=True, exist_ok=True)
113+
tmp = csv_path.with_suffix(csv_path.suffix + ".tmp")
114+
with tmp.open("w", newline="", encoding="utf-8-sig") as fo:
115+
w = csv.DictWriter(fo, fieldnames=_CSV_COLS,
116+
extrasaction="ignore")
117+
w.writeheader()
118+
for p in entries:
119+
w.writerow({k: getattr(p, k, "") for k in _CSV_COLS})
120+
tmp.replace(csv_path)
121+
122+
123+
def _load_resume_rows(csv_path: Path) -> tuple[list, set]:
124+
"""Load previously-completed FreeSolvPoint rows from a partial
125+
CSV. Returns (rows, names_completed). Used by --resume so a
126+
crashed multi-day CPU run picks up where it left off without
127+
re-doing compounds with non-empty dG_pred_kcalmol."""
128+
rows: list = []
129+
names: set = set()
130+
if not csv_path.exists():
131+
return rows, names
132+
with csv_path.open("r", encoding="utf-8-sig", newline="") as fi:
133+
reader = csv.DictReader(fi)
134+
for r in reader:
135+
pred = (r.get("dG_pred_kcalmol") or "").strip()
136+
if not pred or pred == "None":
137+
# Compound was attempted but didn't complete; re-run
138+
# it fresh by not adding to names_completed.
139+
continue
140+
def _f(key):
141+
v = (r.get(key) or "").strip()
142+
if not v or v == "None":
143+
return None
144+
try:
145+
return float(v)
146+
except ValueError:
147+
return None
148+
p = FreeSolvPoint(
149+
name=r.get("name", "").strip(),
150+
smiles=r.get("smiles", "").strip(),
151+
dG_expt_kcalmol=float(
152+
r.get("dG_expt_kcalmol") or "nan"),
153+
dG_pred_kcalmol=_f("dG_pred_kcalmol"),
154+
uncertainty_kcalmol=_f("uncertainty_kcalmol"),
155+
residual_kcalmol=_f("residual_kcalmol"),
156+
wall_seconds=_f("wall_seconds"),
157+
ok=((r.get("ok", "") or "").strip().lower()
158+
in ("true", "1", "yes")),
159+
reason=r.get("reason", "").strip(),
160+
ghmc_accept_mean=_f("ghmc_accept_mean"),
161+
ghmc_accept_min=_f("ghmc_accept_min"),
162+
)
163+
rows.append(p)
164+
names.add(p.name)
165+
return rows, names
166+
167+
101168
def run_freesolv_validation(
102169
yaml_path: str | Path,
103170
*,
@@ -106,8 +173,22 @@ def run_freesolv_validation(
106173
n_equilibration_steps: int = 2500,
107174
sample_stride: int = 250,
108175
seed: int = 1,
176+
out_csv: Optional[Path] = None,
177+
resume: bool = False,
109178
) -> FreeSolvResult:
110-
"""Run the FreeSolv subset and compute MAE + correlations."""
179+
"""Run the FreeSolv subset and compute MAE + correlations.
180+
181+
If `out_csv` is provided, the CSV is written **after every
182+
compound completes** (not just at the end), so a 30+ hour CPU
183+
run that dies mid-way leaves all completed compounds intact for
184+
the next `--resume` invocation. The legacy main() also writes a
185+
final summary CSV when out_csv is given.
186+
187+
If `resume=True` and `out_csv` exists, compounds with a non-
188+
empty `dG_pred_kcalmol` in that file are kept and skipped in
189+
the bench loop. Mid-compound crashes (no pred written) re-run
190+
cleanly because only completed rows are loaded.
191+
"""
111192
import yaml as pyyaml
112193
import numpy as np
113194

@@ -117,7 +198,18 @@ def run_freesolv_validation(
117198
result = FreeSolvResult(yaml_path=str(yaml_path))
118199
t0 = time.time()
119200

201+
completed_names: set = set()
202+
if resume and out_csv:
203+
prior_rows, completed_names = _load_resume_rows(out_csv)
204+
result.entries.extend(prior_rows)
205+
if completed_names:
206+
print(f"[freesolv] --resume: {len(completed_names)} "
207+
f"compound(s) already in {out_csv}, skipping: "
208+
f"{sorted(completed_names)}", flush=True)
209+
120210
for entry in data.get("entries", []):
211+
if entry["name"] in completed_names:
212+
continue
121213
p = FreeSolvPoint(
122214
name=entry["name"],
123215
smiles=entry["smiles"],
@@ -160,6 +252,16 @@ def run_freesolv_validation(
160252
p.reason = r.reason
161253
print(f" FAIL: {r.reason}", flush=True)
162254
result.entries.append(p)
255+
# Atomic incremental write — if a multi-day CPU run dies
256+
# mid-loop (lid close / kernel panic / power), every
257+
# completed compound survives in the CSV for --resume.
258+
if out_csv:
259+
try:
260+
_write_csv(out_csv, result.entries)
261+
except OSError as e:
262+
logger.warning(
263+
"incremental CSV write failed: %s "
264+
"(continuing run)", e)
163265

164266
result.n_total = len(result.entries)
165267
ok = [p for p in result.entries if p.ok]
@@ -197,30 +299,33 @@ def main(argv: Optional[list] = None) -> int:
197299
ap.add_argument("--sample-stride", type=int, default=250)
198300
ap.add_argument("--seed", type=int, default=1)
199301
ap.add_argument("--out-csv", type=Path, default=None,
200-
help="write per-compound results as CSV")
302+
help="write per-compound results as CSV "
303+
"(written incrementally after each "
304+
"compound, so a crashed run preserves "
305+
"completed compounds for --resume)")
306+
ap.add_argument("--resume", action="store_true",
307+
help="if --out-csv exists, skip compounds "
308+
"with a non-empty dG_pred_kcalmol already "
309+
"in it. For restarting a crashed multi-day "
310+
"CPU run without losing completed work.")
201311
args = ap.parse_args(argv)
202312

203313
r = run_freesolv_validation(
204314
args.yaml,
205315
n_windows=args.n_windows,
206316
n_production_steps=args.production_steps,
207317
n_equilibration_steps=args.equilibration_steps,
208-
sample_stride=args.sample_stride, seed=args.seed)
318+
sample_stride=args.sample_stride, seed=args.seed,
319+
out_csv=args.out_csv, resume=args.resume)
209320
print()
210321
print(r.summary())
211322
if args.out_csv:
212-
args.out_csv.parent.mkdir(parents=True, exist_ok=True)
213-
cols = ["name", "smiles", "dG_expt_kcalmol",
214-
"dG_pred_kcalmol", "uncertainty_kcalmol",
215-
"residual_kcalmol", "ghmc_accept_mean",
216-
"ghmc_accept_min", "wall_seconds", "ok", "reason"]
217-
with args.out_csv.open(
218-
"w", newline="", encoding="utf-8-sig") as fo:
219-
w = csv.DictWriter(
220-
fo, fieldnames=cols, extrasaction="ignore")
221-
w.writeheader()
222-
for p in r.entries:
223-
w.writerow({k: getattr(p, k, "") for k in cols})
323+
# Final write — same atomic helper. Idempotent with the
324+
# per-compound incremental writes above; this ensures the
325+
# last row reaches the file even if the loop body's writer
326+
# didn't run for the last compound (e.g. all skipped via
327+
# --resume).
328+
_write_csv(args.out_csv, r.entries)
224329
print(f"\nwrote {args.out_csv}")
225330

226331
if r.mae_kcalmol is not None and r.mae_kcalmol <= 1.5:
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""freesolv_validate --resume regression.
2+
3+
Pins:
4+
- Incremental CSV write after each compound (so a kill mid-run
5+
preserves completed work)
6+
- --resume reads back the CSV and skips compounds with non-empty
7+
dG_pred_kcalmol
8+
- Mid-compound crash (no pred written) doesn't poison the resume
9+
- Final write is idempotent with the incremental writes
10+
11+
Multi-day CPU FreeSolv runs can die on lid-close / power / kernel
12+
panic. Without these properties the user loses ~30h of work; with
13+
them, --resume picks up exactly where the crash happened.
14+
"""
15+
from __future__ import annotations
16+
17+
import csv
18+
import sys
19+
import tempfile
20+
from pathlib import Path
21+
22+
REPO = Path(__file__).resolve().parents[2]
23+
sys.path.insert(0, str(REPO))
24+
25+
from src.fep.freesolv_validate import (
26+
FreeSolvPoint,
27+
_CSV_COLS,
28+
_load_resume_rows,
29+
_write_csv,
30+
)
31+
32+
33+
def _make_point(name, pred=None, ok=True):
34+
return FreeSolvPoint(
35+
name=name, smiles="C" if name == "methane" else "CCO",
36+
dG_expt_kcalmol=2.00 if name == "methane" else -5.01,
37+
dG_pred_kcalmol=pred,
38+
uncertainty_kcalmol=0.3 if pred is not None else None,
39+
residual_kcalmol=(pred - 2.0) if pred is not None else None,
40+
wall_seconds=420.0 if pred is not None else None,
41+
ok=ok, reason="" if ok else "sampling failed: foo",
42+
ghmc_accept_mean=0.85 if pred is not None else None,
43+
ghmc_accept_min=0.72 if pred is not None else None,
44+
)
45+
46+
47+
def test_write_csv_round_trip_via_load_resume():
48+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
49+
p = Path(tmp) / "results.csv"
50+
rows = [_make_point("methane", pred=+1.80),
51+
_make_point("ethanol", pred=-5.30)]
52+
_write_csv(p, rows)
53+
assert p.exists()
54+
loaded, names = _load_resume_rows(p)
55+
assert names == {"methane", "ethanol"}
56+
assert len(loaded) == 2
57+
assert loaded[0].name == "methane"
58+
assert loaded[0].dG_pred_kcalmol == 1.80
59+
assert loaded[1].dG_pred_kcalmol == -5.30
60+
61+
62+
def test_resume_skips_incomplete_compound_so_it_reruns():
63+
"""Compound that started but didn't finish writes a row with
64+
pred=None. --resume must NOT mark it complete; the loop must
65+
re-run that compound from scratch."""
66+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
67+
p = Path(tmp) / "results.csv"
68+
rows = [
69+
_make_point("methane", pred=+1.80),
70+
_make_point("ethane", pred=None, ok=False), # crashed
71+
]
72+
_write_csv(p, rows)
73+
loaded, names = _load_resume_rows(p)
74+
# methane resumes-as-done; ethane is missing → will re-run
75+
assert names == {"methane"}, names
76+
assert len(loaded) == 1
77+
78+
79+
def test_load_resume_handles_missing_file():
80+
"""First run, no CSV exists → empty lists, no crash."""
81+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
82+
p = Path(tmp) / "does_not_exist.csv"
83+
loaded, names = _load_resume_rows(p)
84+
assert loaded == []
85+
assert names == set()
86+
87+
88+
def test_write_csv_is_atomic_via_tmp_rename():
89+
"""If the .tmp file exists and full CSV doesn't, a previous
90+
write was interrupted; current code overwrites tmp cleanly."""
91+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
92+
p = Path(tmp) / "results.csv"
93+
tmp_path = p.with_suffix(p.suffix + ".tmp")
94+
# Plant a stale tmp file from a "killed" prior write
95+
tmp_path.write_text("stale,partial,write,leftover\n")
96+
# New write should clobber and produce a valid CSV
97+
_write_csv(p, [_make_point("methane", pred=+1.80)])
98+
assert p.exists()
99+
# Stale tmp was renamed away (rename clobbers target)
100+
assert not tmp_path.exists()
101+
# And the CSV is readable
102+
loaded, names = _load_resume_rows(p)
103+
assert names == {"methane"}
104+
105+
106+
def test_csv_schema_columns_pinned():
107+
"""Adding a column without thinking can break fep-report. Pin
108+
the exact set + order."""
109+
assert _CSV_COLS == [
110+
"name", "smiles", "dG_expt_kcalmol",
111+
"dG_pred_kcalmol", "uncertainty_kcalmol",
112+
"residual_kcalmol", "ghmc_accept_mean",
113+
"ghmc_accept_min", "wall_seconds", "ok", "reason",
114+
]
115+
116+
117+
def test_resume_loads_partial_run_three_done_one_pending():
118+
"""End-to-end realistic case: 12 compounds, 3 completed, 9
119+
waiting. --resume keeps the 3, the next bench run runs the 9."""
120+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
121+
p = Path(tmp) / "results.csv"
122+
rows = [
123+
_make_point("methane", pred=+1.80),
124+
_make_point("ethane", pred=+1.55),
125+
_make_point("propane", pred=+2.31),
126+
]
127+
_write_csv(p, rows)
128+
loaded, names = _load_resume_rows(p)
129+
assert names == {"methane", "ethane", "propane"}
130+
assert len(loaded) == 3
131+
# The three preds round-tripped exactly
132+
preds = {r.name: r.dG_pred_kcalmol for r in loaded}
133+
assert preds == {"methane": 1.80, "ethane": 1.55,
134+
"propane": 2.31}
135+
136+
137+
def test_csv_compatible_with_fep_report():
138+
"""The incremental writer must produce a CSV that cellsim
139+
fep-report can consume. Smoke-check via a DictReader and
140+
field-presence."""
141+
with tempfile.TemporaryDirectory(prefix="freesolv_resume_") as tmp:
142+
p = Path(tmp) / "results.csv"
143+
_write_csv(p, [_make_point("methane", pred=+1.80)])
144+
with p.open("r", encoding="utf-8-sig") as fi:
145+
r = next(csv.DictReader(fi))
146+
for col in ("name", "smiles", "dG_expt_kcalmol",
147+
"dG_pred_kcalmol", "wall_seconds", "ok"):
148+
assert col in r, f"missing column {col}"
149+
150+
151+
if __name__ == "__main__":
152+
funcs = [
153+
test_write_csv_round_trip_via_load_resume,
154+
test_resume_skips_incomplete_compound_so_it_reruns,
155+
test_load_resume_handles_missing_file,
156+
test_write_csv_is_atomic_via_tmp_rename,
157+
test_csv_schema_columns_pinned,
158+
test_resume_loads_partial_run_three_done_one_pending,
159+
test_csv_compatible_with_fep_report,
160+
]
161+
fails = []
162+
for f in funcs:
163+
try:
164+
f()
165+
print(f"[PASS] {f.__name__}")
166+
except AssertionError as e:
167+
print(f"[FAIL] {f.__name__}: {e}")
168+
fails.append(f.__name__)
169+
except Exception as e:
170+
import traceback
171+
traceback.print_exc()
172+
print(f"[ERROR] {f.__name__}: {e}")
173+
fails.append(f.__name__)
174+
print(f"{len(funcs) - len(fails)}/{len(funcs)} PASS")
175+
sys.exit(0 if not fails else 1)

0 commit comments

Comments
 (0)